一步一步實現字母索引導航欄

先來看下實現後的效果:javascript

DEMO

連接:在線DEMO源代碼html

這個索引導航欄的效果在不少 APP 中都有應用,我也是參考了一些 APP 的效果進行實現。java

不過以前接觸移動端頁面開發較少,因此是邊學邊作,也就把這個過程當中的一些東西整理記錄下來。git

設計

這個功能的基本需求能夠總結爲一句話:手指在導航欄(也就是 DEMO 上頁面右側的包含字母的豎條)拖動時,根據當前手指位置,頁面主體內容列表跳轉到對應字母的內容項。github

固然,延伸開來,能夠是對於已經排序的列表,導航欄顯示對應的索引字符列表,支持快速跳轉到對應的索引位置。npm

這裏主要介紹導航欄的實現,只看導航欄的話,其實要實現的東西比較簡單,只須要在手指移動時獲取對應的字母便可。頁面主體內容列表的跳轉應該交由另外一個列表組件實現。數組

在程序代碼中,組合導航欄和內容列表兩個組件,導航欄索引字母更新時,內容列表跳轉到對應的位置。瀏覽器

結合 DEMO,總體的實現邏輯爲:ide

// 建立一個內容列表組件
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/...

最後,特別歡迎使用:

npm i index-sidebar

感謝閱讀!

相關文章
相關標籤/搜索