一步步實現字母索引導航欄
先來看下實現后的效果:
這個索引導航欄的效果在很多 APP 中都有應用,我也是參考了一些 APP 的效果進行實現。
不過之前接觸移動端頁面開發較少,所以是邊學邊做,也就把這個過程中的一些東西整理記錄下來。
設計
這個功能的基本需求可以總結為一句話:手指在導航欄(也就是 DEMO 上頁面右側的包含字母的豎條)拖動時,根據當前手指位置,頁面主體內容列表跳轉到對應字母的內容項。
當然,延伸開來,可以是對于已經排序的列表,導航欄顯示對應的索引字符列表,支持快速跳轉到對應的索引位置。
這里主要介紹導航欄的實現,只看導航欄的話,其實要實現的東西比較簡單,只需要在手指移動時獲取對應的字母即可。頁面主體內容列表的跳轉應該交由另一個列表組件實現。
在程序代碼中,組合導航欄和內容列表兩個組件,導航欄索引字母更新時,內容列表跳轉到對應的位置。
結合 DEMO,整體的實現邏輯為:
// 創建一個內容列表組件
var itemList = new ItemList(data)
// 創建一個索引導航組件
var indexSidebar = new IndexSidebar()
// 組合兩個組件實現功能
// 監聽索引導航組件,一旦索引字符更新,內容列表跳轉至對應的索引字符
indexSidebar.on('charChange', function (ch) {
itemList.gotoChar(ch)
})
接下來,我們一步步實現。
第 1 步:創建 IndexSidebar “類”
我選擇采用實例化“類”的方式來創建新的組件對象,定義“類”,其實就是創建一個構造函數(當然,采用 ES6 語法會更清晰,不過考慮兼容性這里不使用):
function IndexSidebar(options) {
// TODO 處理 options
this.initialize(options)
}
IndexSidebar.prototype.initialize = function (options) {
// TODO 初始化
}
這里借鑒 Backbone 的模式,將組件初始化的邏輯單獨寫在一個 initialize() 方法中,當然邏輯也可以都寫在構造函數中。
在實現具體的功能前,我們可以先讓前面設計的代碼跑起來,首先補全導航組件的接口方法,支持監聽事件:
// 特定事件觸發時,調用傳入的回調函數并傳入事件數據
IndexSidebar.prototype.on = function (event, callback) {
// TODO 實現事件監聽
}
這里選擇采用事件模式(或者說觀察者模式吧),這樣可以有多個“觀察者”,為了完整,同樣借鑒已有的模式實現,我們補全其他會用到的事件接口方法:
// 觸發特定事件,并給出事件數據供監聽的回調函數使用
IndexSidebar.prototype.trigger = function (event, data) {
// TODO
}
// 解除事件監聽
IndexSidebar.prototype.off = function (event, callback) {
// TODO
}
接著來搭個列表組件的架子,同樣是類的模式,不過簡單點,畢竟主要是為了實現索引導航欄組件,列表組件只是輔助:
// 內容列表組件
function ItemList(data) {
return {
gotoChar: function (ch) {
// TODO 實現按索引字符跳轉功能
}
}
}
這里偷懶了,雖然兼容 new ItemList(data) 的用法,但其實并沒有按照“類”的模式實現。
好了,有了上面的這些代碼,前面的設計應該可以運行了....雖然現在沒什么用。
第 2 步:實現手指拖動更新索引字母
我們首先解決導航組件最重要的交互功能,也就是手指拖動的動作處理。由于之前沒做過觸摸的功能,我只好先查下相關的事件用法(當然,盡管沒用過,還是知道有相關的事件):
看了上面這些文檔,我發現 touch 相關的事件還有個特殊的事件數據,對應的是手指觸摸屏幕的位置: Touch - MDN ,顯然這個數據是會用到的。
之前做 PC 頁面的時候,也做過類似的鼠標拖動的處理,使用到的瀏覽器事件主要是:mousedown, mousemove, mouseup。大致的處理邏輯是:
- 鼠標按下(mousedown)時,記錄拖動開始
- 鼠標移動(mousemove)時,如果拖動開始,則根據鼠標位置更新并計算相關數據
- 鼠標松開(mouseup)時,記錄拖動結束
這個邏輯也可以用在手指觸摸的拖動上。注意一個小細節,手指在屏幕上觸摸時,可能同時有多個位置,所以觸摸事件的位置相關數據是一個列表: TouchList - MDN 。不過我這里不關心,只取列表中的第一個位置數據使用。
這一部分的代碼邏輯實現為:
IndexSidebar.prototype.initEvents = function (options) {
var el = this.el // el 對應導航欄容器元素,初始化過程略
var touching = false
el.addEventListener('touchstart', function (e) {
if (!touching) {
// 取消缺省行為,否則在 iOS 環境中會出現頁面上下抖動
e.preventDefault()
var t = e.touches[0]
start(t.clientX, t.clientY)
}
}, false)
// 拖動過程中手指可能會移出導航欄,所以是在 document 上監聽
// 不過貌似在 el 上監聽也可以,這個暫不討論了
// 后面的 touchend 也是類似的緣故
document.addEventListener('touchmove', function handler(e) {
if (touching) {
e.preventDefault()
var t = e.touches[0]
move(t.clientX, t.clientY)
}
}, false)
document.addEventListener('touchend', function (e) {
if (touching) {
e.preventDefault()
end()
}
}, false)
// TODO 實現索引字符的更新
function start(clientX, clientY) {}
function move(clientX, clientY) {}
function end() {}
}
之所以抽出 start() , move() , end() 三個函數,是為了在 PC 瀏覽器器上支持鼠標的拖動,這樣監聽鼠標拖動相關事件時,也能使用這里的邏輯。
怎么計算手指觸摸位置的字符呢?這個我想大家應該都能想到,我這里采用的是比較笨的方法,就是根據觸摸位置計算索引導航欄中的距離最近的字符,大致過程為:
- 已知手指相對屏幕(其實是視口,這里不區分了)位置(clientX, clientY)和索引字符數組(chars)
- 獲取索引導航組件距屏幕頂部的距離(boxClientTop)和自身的高度(boxHeight)
- 計算得到手指位置在組件內部的相對高度(offsetY): offsetY = clientY - boxClientTop
- 根據手指位置的相對高度與組件高度的比例,從索引字符數組中取出對應位置的字符(略,這個不難算)
這里就不貼代碼了,都是一些瑣碎的計算,還要額外考慮手指位置在豎直方向上超出導航欄范圍的情況。
經過以上計算,可以得到一個索引字符 ch ,接下來要做的就是通知“觀察者”們,字符更新了(如果和上一個索引字符不同的話):
this.trigger('charChange', ch)
第 3 步:實現組件事件接口
這個其實可以不必多寫,類似的實現有很多。不過為了不依賴其他庫,我選擇自己實現。我就直接貼自己實現的版本了:
/* Event Emitter API */
IndexSidebar.prototype.trigger = function (event, data) {
var listeners = this._listeners && this._listeners[event]
if (listeners) {
listeners.forEach(function (listener) {
listener(data)
})
}
}
IndexSidebar.prototype.on = function (event, callback) {
this._listeners = this._listeners || {}
var listeners = this._listeners[event] || (this._listeners[event] = [])
listeners.push(callback)
}
IndexSidebar.prototype.off = function (event, callback) {
var listeners = this._listeners && this._listeners[event]
if (listeners) {
var i = listeners.indexOf(callback)
if (i > -1) {
listeners.splice(i, 1)
if (listeners.length === 0) {
this._listeners[event] = null
}
}
}
}
使用對象屬性 _listeners 來記錄事件監聽函數,當然這里可以只實現成單個數組,不必搞得這么復雜。不過為了可能的組件擴展的需要,還是這么實現了,這樣如果還需要支持其他類型的事件,例如對外暴露觸摸開始事件“touchStarted”,事件接口這里就不需要修改了。
第 4 步:實現內容列表跳轉至索引字符
到這里其實索引導航欄組件的開發已經結束,不過畢竟看不到效果嘛,所以就實現了簡單的內容列表組件,從而可以對導航欄組件進行測試。
內容列表組件在創建時,傳入了數據,根據這些數據渲染出列表,并且在渲染的過程中記錄索引,從而在輸出的 HTML 結構上做出標記,以便查找并跳轉:
// 內容列表組件
function ItemList(data) {
var list = []
var map = {}
var html
html = data.map(function (item) {
// 數組中每項為 "Angola 安哥拉" 的形式,且已排序
var i = item.lastIndexOf(' ')
var en = item.slice(0, i)
var cn = item.slice(i + 1)
var ch = en[0]
if (map[ch]) {
return '<li>' + en + '<br>' + cn + '</li>'
} else {
// 同一索引字符首次出現時,在 HTML 上標記
map[ch] = true
return '<li data-ch="' + ch + '">' + en + '<br>' + cn + '</li>'
}
}).join('')
var elItemList = document.querySelector('#item-container ul')
elItemList.innerHTML = html
return {
gotoChar: function (ch) {
// TODO 實現按索引字符跳轉功能
}
}
}
由于已在 HTML 結構上標記了索引字符,所以 gotoChar 的邏輯其實就是找帶有標記的元素,然后讓其移動滾動到組件頂部顯示:
return {
gotoChar: function (ch) {
if (ch === '*') {
// 滾動至頂部
elItemList.scrollTop = 0
} else if (ch === '#') {
// 滾動至底部
elItemList.scrollTop = elItemList.scrollHeight
} else {
// 滾動至特定索引字符處
var target = elItemList.querySelector('[data-ch="' + ch + '"]')
if (target) {
target.scrollIntoView()
}
}
}
}
OK,以上就是所有的邏輯了。
第 5 步:完善索引導航組件
其實基本功能已經實現,不過既然是想作為開源組件發布,還是再“包裝”下,主要做了以下幾方面的完善:
-
支持根據屏幕高度調整導航欄的高度
計算屏幕高度,和組件距離屏幕頂部和底部的距離,將索引字符平均分布。
-
支持組件配置選項,并提供缺省選項
由于不想依賴其他庫,且考慮兼容性(不能使用 Object.assign),所以自己實現了:
var defaultOptions = { chars: '*ABCDEFGHIJKLMNOPQRSTUVWXYZ#', isAdjust: true, // 是否需要自動調整導航欄高度 offsetTop: 70, offsetBottom: 10, lineScale: 0.7, charOffsetX: 80, charOffsetY: 20 } function IndexSidebar(options) { options = options || {} // 遍歷缺省選項逐一處理 for (var k in defaultOptions) { if (defaultOptions.hasOwnProperty(k)) { // 未給出選項值時使用缺省選項值 options[k] = options[k] || defaultOptions[k] } } this.options = options this.initialize(options) }
-
支持不同的方式引用組件
這個和一般的模塊差不多,不過額外支持了一下 SeaJS(define.cmd):
(function (factory) { if (typeof module === 'object' && module.export) { module.export = factory() } else if (typeof define === 'function' && (define.amd || define.cmd)) { define([], factory) } else if (typeof window !== 'undefined') { window.IndexSidebar = factory() } })(function () { // ... return IndexSidebar })
總結
從看到這個需求,到查文檔、設計、實現,以及作為開源工具發布,用了大概不到 1 天的時間。希望可以有同學能夠從我的這個過程中收獲一些東西吧。
當然,也歡迎提出意見、建議,更歡迎參與完善這個組件:
https://github.com/luobotang/index-sidebar
最后,特別歡迎使用:
npm i index-sidebar
感謝閱讀!
來自:http://www.jianshu.com/p/6b9af9373a14