一步一步實(shí)現(xiàn)字母索引導(dǎo)航欄

先來看下實(shí)現(xiàn)后的效果:

DEMO
DEMO

鏈接:在線DEMO源代碼

這個(gè)索引導(dǎo)航欄的效果在很多 APP 中都有應(yīng)用,我也是參考了一些 APP 的效果進(jìn)行實(shí)現(xiàn)。

不過之前接觸移動(dòng)端頁面開發(fā)較少,所以是邊學(xué)邊做,也就把這個(gè)過程中的一些東西整理記錄下來。

設(shè)計(jì)

這個(gè)功能的基本需求可以總結(jié)為一句話:手指在導(dǎo)航欄(也就是 DEMO 上頁面右側(cè)的包含字母的豎條)拖動(dòng)時(shí),根據(jù)當(dāng)前手指位置,頁面主體內(nèi)容列表跳轉(zhuǎn)到對(duì)應(yīng)字母的內(nèi)容項(xiàng)。

當(dāng)然,延伸開來,可以是對(duì)于已經(jīng)排序的列表,導(dǎo)航欄顯示對(duì)應(yīng)的索引字符列表,支持快速跳轉(zhuǎn)到對(duì)應(yīng)的索引位置。

這里主要介紹導(dǎo)航欄的實(shí)現(xiàn),只看導(dǎo)航欄的話,其實(shí)要實(shí)現(xiàn)的東西比較簡單,只需要在手指移動(dòng)時(shí)獲取對(duì)應(yīng)的字母即可。頁面主體內(nèi)容列表的跳轉(zhuǎn)應(yīng)該交由另一個(gè)列表組件實(shí)現(xiàn)。

在程序代碼中,組合導(dǎo)航欄和內(nèi)容列表兩個(gè)組件,導(dǎo)航欄索引字母更新時(shí),內(nèi)容列表跳轉(zhuǎn)到對(duì)應(yīng)的位置。

結(jié)合 DEMO,整體的實(shí)現(xiàn)邏輯為:

// 創(chuàng)建一個(gè)內(nèi)容列表組件
var itemList = new ItemList(data)

// 創(chuàng)建一個(gè)索引導(dǎo)航組件
var indexSidebar = new IndexSidebar()

// 組合兩個(gè)組件實(shí)現(xiàn)功能
// 監(jiān)聽索引導(dǎo)航組件,一旦索引字符更新,內(nèi)容列表跳轉(zhuǎn)至對(duì)應(yīng)的索引字符
indexSidebar.on('charChange', function (ch) {
  itemList.gotoChar(ch)
})

接下來,我們一步步實(shí)現(xiàn)。

第 1 步:創(chuàng)建 IndexSidebar “類”

我選擇采用實(shí)例化“類”的方式來創(chuàng)建新的組件對(duì)象,定義“類”,其實(shí)就是創(chuàng)建一個(gè)構(gòu)造函數(shù)(當(dāng)然,采用 ES6 語法會(huì)更清晰,不過考慮兼容性這里不使用):

function IndexSidebar(options) {
  // TODO 處理 options
  this.initialize(options)
}

IndexSidebar.prototype.initialize = function (options) {
  // TODO 初始化
}

這里借鑒 Backbone 的模式,將組件初始化的邏輯單獨(dú)寫在一個(gè) initialize() 方法中,當(dāng)然邏輯也可以都寫在構(gòu)造函數(shù)中。

在實(shí)現(xiàn)具體的功能前,我們可以先讓前面設(shè)計(jì)的代碼跑起來,首先補(bǔ)全導(dǎo)航組件的接口方法,支持監(jiān)聽事件:

// 特定事件觸發(fā)時(shí),調(diào)用傳入的回調(diào)函數(shù)并傳入事件數(shù)據(jù)
IndexSidebar.prototype.on = function (event, callback) {
  // TODO 實(shí)現(xiàn)事件監(jiān)聽
}

這里選擇采用事件模式(或者說觀察者模式吧),這樣可以有多個(gè)“觀察者”,為了完整,同樣借鑒已有的模式實(shí)現(xiàn),我們補(bǔ)全其他會(huì)用到的事件接口方法:

// 觸發(fā)特定事件,并給出事件數(shù)據(jù)供監(jiān)聽的回調(diào)函數(shù)使用
IndexSidebar.prototype.trigger = function (event, data) {
  // TODO
}

// 解除事件監(jiān)聽
IndexSidebar.prototype.off = function (event, callback) {
  // TODO
}

接著來搭個(gè)列表組件的架子,同樣是類的模式,不過簡單點(diǎn),畢竟主要是為了實(shí)現(xiàn)索引導(dǎo)航欄組件,列表組件只是輔助:

// 內(nèi)容列表組件
function ItemList(data) {
  return {
    gotoChar: function (ch) {
      // TODO 實(shí)現(xiàn)按索引字符跳轉(zhuǎn)功能
    }
  }
}

這里偷懶了,雖然兼容 new ItemList(data) 的用法,但其實(shí)并沒有按照“類”的模式實(shí)現(xiàn)。

好了,有了上面的這些代碼,前面的設(shè)計(jì)應(yīng)該可以運(yùn)行了....雖然現(xiàn)在沒什么用。

第 2 步:實(shí)現(xiàn)手指拖動(dòng)更新索引字母

我們首先解決導(dǎo)航組件最重要的交互功能,也就是手指拖動(dòng)的動(dòng)作處理。由于之前沒做過觸摸的功能,我只好先查下相關(guān)的事件用法(當(dāng)然,盡管沒用過,還是知道有相關(guān)的事件):

看了上面這些文檔,我發(fā)現(xiàn) touch 相關(guān)的事件還有個(gè)特殊的事件數(shù)據(jù),對(duì)應(yīng)的是手指觸摸屏幕的位置:Touch - MDN,顯然這個(gè)數(shù)據(jù)是會(huì)用到的。

之前做 PC 頁面的時(shí)候,也做過類似的鼠標(biāo)拖動(dòng)的處理,使用到的瀏覽器事件主要是:mousedown, mousemove, mouseup。大致的處理邏輯是:

  • 鼠標(biāo)按下(mousedown)時(shí),記錄拖動(dòng)開始
  • 鼠標(biāo)移動(dòng)(mousemove)時(shí),如果拖動(dòng)開始,則根據(jù)鼠標(biāo)位置更新并計(jì)算相關(guān)數(shù)據(jù)
  • 鼠標(biāo)松開(mouseup)時(shí),記錄拖動(dòng)結(jié)束

這個(gè)邏輯也可以用在手指觸摸的拖動(dòng)上。注意一個(gè)小細(xì)節(jié),手指在屏幕上觸摸時(shí),可能同時(shí)有多個(gè)位置,所以觸摸事件的位置相關(guān)數(shù)據(jù)是一個(gè)列表:TouchList - MDN。不過我這里不關(guān)心,只取列表中的第一個(gè)位置數(shù)據(jù)使用。

這一部分的代碼邏輯實(shí)現(xiàn)為:

IndexSidebar.prototype.initEvents = function (options) {
  var el = this.el // el 對(duì)應(yīng)導(dǎo)航欄容器元素,初始化過程略
  var touching = false

  el.addEventListener('touchstart', function (e) {
    if (!touching) {
      // 取消缺省行為,否則在 iOS 環(huán)境中會(huì)出現(xiàn)頁面上下抖動(dòng)
      e.preventDefault()
      var t = e.touches[0]
      start(t.clientX, t.clientY)
    }
  }, false)

  // 拖動(dòng)過程中手指可能會(huì)移出導(dǎo)航欄,所以是在 document 上監(jiān)聽
  // 不過貌似在 el 上監(jiān)聽也可以,這個(gè)暫不討論了
  // 后面的 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 實(shí)現(xiàn)索引字符的更新
  function start(clientX, clientY) {}
  function move(clientX, clientY) {}
  function end() {}
}

之所以抽出 start(), move(), end() 三個(gè)函數(shù),是為了在 PC 瀏覽器器上支持鼠標(biāo)的拖動(dòng),這樣監(jiān)聽鼠標(biāo)拖動(dòng)相關(guān)事件時(shí),也能使用這里的邏輯。

怎么計(jì)算手指觸摸位置的字符呢?這個(gè)我想大家應(yīng)該都能想到,我這里采用的是比較笨的方法,就是根據(jù)觸摸位置計(jì)算索引導(dǎo)航欄中的距離最近的字符,大致過程為:

  • 已知手指相對(duì)屏幕(其實(shí)是視口,這里不區(qū)分了)位置(clientX, clientY)和索引字符數(shù)組(chars)
  • 獲取索引導(dǎo)航組件距屏幕頂部的距離(boxClientTop)和自身的高度(boxHeight)
  • 計(jì)算得到手指位置在組件內(nèi)部的相對(duì)高度(offsetY):offsetY = clientY - boxClientTop
  • 根據(jù)手指位置的相對(duì)高度與組件高度的比例,從索引字符數(shù)組中取出對(duì)應(yīng)位置的字符(略,這個(gè)不難算)

這里就不貼代碼了,都是一些瑣碎的計(jì)算,還要額外考慮手指位置在豎直方向上超出導(dǎo)航欄范圍的情況。

經(jīng)過以上計(jì)算,可以得到一個(gè)索引字符 ch,接下來要做的就是通知“觀察者”們,字符更新了(如果和上一個(gè)索引字符不同的話):

this.trigger('charChange', ch)

第 3 步:實(shí)現(xiàn)組件事件接口

這個(gè)其實(shí)可以不必多寫,類似的實(shí)現(xiàn)有很多。不過為了不依賴其他庫,我選擇自己實(shí)現(xiàn)。我就直接貼自己實(shí)現(xiàn)的版本了:

/* 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
      }
    }
  }
}

使用對(duì)象屬性 _listeners 來記錄事件監(jiān)聽函數(shù),當(dāng)然這里可以只實(shí)現(xiàn)成單個(gè)數(shù)組,不必搞得這么復(fù)雜。不過為了可能的組件擴(kuò)展的需要,還是這么實(shí)現(xiàn)了,這樣如果還需要支持其他類型的事件,例如對(duì)外暴露觸摸開始事件“touchStarted”,事件接口這里就不需要修改了。

第 4 步:實(shí)現(xiàn)內(nèi)容列表跳轉(zhuǎn)至索引字符

到這里其實(shí)索引導(dǎo)航欄組件的開發(fā)已經(jīng)結(jié)束,不過畢竟看不到效果嘛,所以就實(shí)現(xiàn)了簡單的內(nèi)容列表組件,從而可以對(duì)導(dǎo)航欄組件進(jìn)行測試。

內(nèi)容列表組件在創(chuàng)建時(shí),傳入了數(shù)據(jù),根據(jù)這些數(shù)據(jù)渲染出列表,并且在渲染的過程中記錄索引,從而在輸出的 HTML 結(jié)構(gòu)上做出標(biāo)記,以便查找并跳轉(zhuǎn):

// 內(nèi)容列表組件
function ItemList(data) {
  var list = []
  var map = {}
  var html

  html = data.map(function (item) {
    // 數(shù)組中每項(xiàng)為 "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 {
      // 同一索引字符首次出現(xiàn)時(shí),在 HTML 上標(biāo)記
      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 實(shí)現(xiàn)按索引字符跳轉(zhuǎn)功能
    }
  }
}

由于已在 HTML 結(jié)構(gòu)上標(biāo)記了索引字符,所以 gotoChar 的邏輯其實(shí)就是找?guī)в袠?biāo)記的元素,然后讓其移動(dòng)滾動(dòng)到組件頂部顯示:

  return {
    gotoChar: function (ch) {
      if (ch === '*') {
        // 滾動(dòng)至頂部
        elItemList.scrollTop = 0
      } else if (ch === '#') {
        // 滾動(dòng)至底部
        elItemList.scrollTop = elItemList.scrollHeight
      } else {
        // 滾動(dòng)至特定索引字符處
        var target = elItemList.querySelector('[data-ch="' + ch + '"]')
        if (target) {
          target.scrollIntoView()
        }
      }
    }
  }

OK,以上就是所有的邏輯了。

第 5 步:完善索引導(dǎo)航組件

其實(shí)基本功能已經(jīng)實(shí)現(xiàn),不過既然是想作為開源組件發(fā)布,還是再“包裝”下,主要做了以下幾方面的完善:

  • 支持根據(jù)屏幕高度調(diào)整導(dǎo)航欄的高度

    計(jì)算屏幕高度,和組件距離屏幕頂部和底部的距離,將索引字符平均分布。

  • 支持組件配置選項(xiàng),并提供缺省選項(xiàng)

    由于不想依賴其他庫,且考慮兼容性(不能使用 Object.assign),所以自己實(shí)現(xiàn)了:

    var defaultOptions = {
      chars: '*ABCDEFGHIJKLMNOPQRSTUVWXYZ#',
      isAdjust: true, // 是否需要自動(dòng)調(diào)整導(dǎo)航欄高度
      offsetTop: 70,
      offsetBottom: 10,
      lineScale: 0.7,
      charOffsetX: 80,
      charOffsetY: 20
    }
    
    function IndexSidebar(options) {
      options = options || {}
    
      // 遍歷缺省選項(xiàng)逐一處理
      for (var k in defaultOptions) {
        if (defaultOptions.hasOwnProperty(k)) {
          // 未給出選項(xiàng)值時(shí)使用缺省選項(xiàng)值
          options[k] = options[k] || defaultOptions[k]
        }
      }
    
      this.options = options
      this.initialize(options)
    }
    
  • 支持不同的方式引用組件

    這個(gè)和一般的模塊差不多,不過額外支持了一下 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
    })
    

總結(jié)

從看到這個(gè)需求,到查文檔、設(shè)計(jì)、實(shí)現(xiàn),以及作為開源工具發(fā)布,用了大概不到 1 天的時(shí)間。希望可以有同學(xué)能夠從我的這個(gè)過程中收獲一些東西吧。

當(dāng)然,也歡迎提出意見、建議,更歡迎參與完善這個(gè)組件:
https://github.com/luobotang/index-sidebar

最后,特別歡迎使用:

npm i index-sidebar

感謝閱讀!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,327評(píng)論 6 537
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,996評(píng)論 3 423
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,316評(píng)論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,406評(píng)論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,128評(píng)論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,524評(píng)論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,576評(píng)論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,759評(píng)論 0 289
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,310評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,065評(píng)論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,249評(píng)論 1 371
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,821評(píng)論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,479評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,909評(píng)論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,140評(píng)論 1 290
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,984評(píng)論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,228評(píng)論 2 375

推薦閱讀更多精彩內(nèi)容