經過幾天的反復折騰,總算做出一個體驗還不錯的列表頁了,主要支持了下拉刷新,上拉加載兩個功能。
一開始直接采用了react-iscroll插件,它是基於iscroll插件開發的組件。但是開發過程中,發現它內部封裝的行為非常固化,限制了我對iscroll的控制能力,因此我轉而直接基於iscroll插件實現。
網上也有一些基於瀏覽器原生滾動條實現的方案,找不到特別好的博客說明,而iscroll是基於Js模擬的滾動條(滾動條也是一個div哦),其兼容性更好,所以還是選擇iscroll吧。
先體驗效果
在講解實現之前,可以先體驗一下app整體效果。如果使用桌面瀏覽器訪問,必須進入開發者模式,啟動手機仿真,並使用鼠標左鍵觸發滑動,否則無法達到真機效果(點我進入)!建議還是掃描二維碼直接在手機瀏覽器中體驗,二維碼如下:
下載demo源碼
點擊這里下載源碼,之后一起看一下實現中需要注意的事項和思路。
實現關鍵點
本篇實現了MsgListPage這個組件,支持消息列表的滾動查看,下拉刷新,上拉加載功能。
這里使用了開源的iscroll5實現滾動功能,它對iscroll4重構並修復若干bug,是目前主流版本。網上鮮有iscroll5實現下拉刷新,上拉加載功能的好例子,提供的僅是一些思路,絕大多數實現都是修改iscroll5源碼,並不完美。我這次的實現不需要修改iscroll5源碼,其實通過巧妙的設計是可以完美的實現這些特效的。
代碼如下:
import React from "react"; import {Link} from "react-router"; import $ from "jquery"; import style from "./MsgListPage.css"; import iScroll from "iscroll/build/iscroll-probe"; // 只有這個庫支持onScroll,從而支持bounce階段的事件捕捉 export default class MsgListPage extends React.Component { constructor(props, context) { super(props, context); this.state = { items: [], pullDownStatus: 3, pullUpStatus: 0, }; this.page = 1; this.itemsChanged = false; this.pullDownTips = { // 下拉狀態 0: '下拉發起刷新', 1: '繼續下拉刷新', 2: '松手即可刷新', 3: '正在刷新', 4: '刷新成功', }; this.pullUpTips = { // 上拉狀態 0: '上拉發起加載', 1: '松手即可加載', 2: '正在加載', 3: '加載成功', }; this.isTouching = false; this.onItemClicked = this.onItemClicked.bind(this); this.onScroll = this.onScroll.bind(this); this.onScrollEnd = this.onScrollEnd.bind(this); this.onTouchStart = this.onTouchStart.bind(this); this.onTouchEnd = this.onTouchEnd.bind(this); } componentDidMount() { const options = { // 默認iscroll會攔截元素的默認事件處理函數,我們需要響應onClick,因此要配置 preventDefault: false, // 禁止縮放 zoom: false, // 支持鼠標事件,因為我開發是PC鼠標模擬的 mouseWheel: true, // 滾動事件的探測靈敏度,1-3,越高越靈敏,兼容性越好,性能越差 probeType: 3, // 拖拽超過上下界后出現彈射動畫效果,用於實現下拉/上拉刷新 bounce: true, // 展示滾動條 scrollbars: true, }; this.iScrollInstance = new iScroll(`#${style.ListOutsite}`, options); this.iScrollInstance.on('scroll', this.onScroll); this.iScrollInstance.on('scrollEnd', this.onScrollEnd); this.fetchItems(true); } fetchItems(isRefresh) { if (isRefresh) { this.page = 1; } $.ajax({ url: '/msg-list', data: {page: this.page}, type: 'GET', dataType: 'json', success: (response) => { if (isRefresh) { // 刷新操作 if (this.state.pullDownStatus == 3) { this.setState({ pullDownStatus: 4, items: response.data.items }); this.iScrollInstance.scrollTo(0, -1 * $(this.refs.PullDown).height(), 500); } } else { // 加載操作 if (this.state.pullUpStatus == 2) { this.setState({ pullUpStatus: 0, items: this.state.items.concat(response.data.items) }); } } ++this.page; console.log(`fetchItems=effected isRefresh=${isRefresh}`); } }); } /** * 點擊跳轉詳情頁 */ onItemClicked(ev) { // 獲取對應的DOM節點, 轉換成jquery對象 let item = $(ev.target); // 操作router實現頁面切換 this.context.router.push(item.attr('to')); this.context.router.goForward(); } onTouchStart(ev) { this.isTouching = true; } onTouchEnd(ev) { this.isTouching = false; } onPullDown() { // 手勢 if (this.isTouching) { if (this.iScrollInstance.y > 5) { this.state.pullDownStatus != 2 && this.setState({pullDownStatus: 2}); } else { this.state.pullDownStatus != 1 && this.setState({pullDownStatus: 1}); } } } onPullUp() { // 手勢 if (this.isTouching) { if (this.iScrollInstance.y <= this.iScrollInstance.maxScrollY - 5) { this.state.pullUpStatus != 1 && this.setState({pullUpStatus: 1}); } else { this.state.pullUpStatus != 0 && this.setState({pullUpStatus: 0}); } } } onScroll() { let pullDown = $(this.refs.PullDown); // 上拉區域 if (this.iScrollInstance.y > -1 * pullDown.height()) { this.onPullDown(); } else { this.state.pullDownStatus != 0 && this.setState({pullDownStatus: 0}); } // 下拉區域 if (this.iScrollInstance.y <= this.iScrollInstance.maxScrollY + 5) { this.onPullUp(); } } onScrollEnd() { console.log("onScrollEnd" + this.state.pullDownStatus); let pullDown = $(this.refs.PullDown); // 滑動結束后,停在刷新區域 if (this.iScrollInstance.y > -1 * pullDown.height()) { if (this.state.pullDownStatus <= 1) { // 沒有發起刷新,那么彈回去 this.iScrollInstance.scrollTo(0, -1 * $(this.refs.PullDown).height(), 200); } else if (this.state.pullDownStatus == 2) { // 發起了刷新,那么更新狀態 this.setState({pullDownStatus: 3}); this.fetchItems(true); } } // 滑動結束后,停在加載區域 if (this.iScrollInstance.y <= this.iScrollInstance.maxScrollY) { if (this.state.pullUpStatus == 1) { // 發起了加載,那么更新狀態 this.setState({pullUpStatus: 2}); this.fetchItems(false); } } } shouldComponentUpdate(nextProps, nextState) { // 列表發生了變化, 那么應該在componentDidUpdate時調用iscroll進行refresh this.itemsChanged = nextState.items !== this.state.items; return true; } componentDidUpdate() { // 僅當列表發生了變更,才調用iscroll的refresh重新計算滾動條信息 if (this.itemsChanged) { this.iScrollInstance.refresh(); } return true; } render() { let lis = []; this.state.items.forEach((item, index) => { lis.push( <li key={index} to={`/msg-detail-page/${index}`} onClick={this.onItemClicked}> {item.title}{index} </li> ); }) // 外層容器要固定高度,才能使用滾動條 return ( <div id={style.ScrollContainer}> <div id={style.ListOutsite} style={{height: window.innerHeight}} onTouchStart={this.onTouchStart} onTouchEnd={this.onTouchEnd}> <ul id={style.ListInside}> <p ref="PullDown" id={style.PullDown}>{this.pullDownTips[this.state.pullDownStatus]}</p> {lis} <p ref="PullUp" id={style.PullUp}>{this.pullUpTips[this.state.pullUpStatus]}</p> </ul> </div> </div> ); } } MsgListPage.contextTypes = { router: () => { React.PropTypes.object.isRequired } };
思路
- 在react的componentDidMount回調中,DOM已經渲染完成。此時進行iscroll插件的初始化,監聽其scroll和scrollEnd兩個插件回調用於滾動監聽,同時,調用fetchItems發起首次數據加載。
- 在react的shouldComponentUpdate回調中,我判斷並記錄本次render是否對ul的元素進行了增刪,從而在componentDidUpdate回調中決策是否需要為iscroll進行refresh刷新,因為如果iscroll容器內的元素數量發生變動,iscroll是需要重新計算整個高度等信息的。
- 為了獲知用戶是否在觸屏,我給div注冊了onTouchStart和onTouchEnd兩個事件函數,這主要是為了區分滾動條是因為觸屏拖拽移動,還是因為慣性移動。
- 在iscroll的onScroll回調中,專門處理用戶的觸屏行為。我判斷y坐標確認當前滾動條所處的范圍是頂部的上拉區域,還是底部的下拉區域。當處於上拉區域中的時候,根據拖拽的偏移量展現不同的文案,下拉區域也是一樣。
- 在iscroll的onScrollEnd回調中,專門處理滾動結束后的狀態判斷,主要是判斷用戶是否此前的觸屏行為是否觸發了下載需求,如果產生了下載需求那么發起網絡調用fetchItems。
- 需要注意,下拉刷新條也位於iscroll容器內,在它能被用戶可見但又沒有抵達刷新觸發偏移量之前,如果用戶沒有觸屏那么應該立即向上滾動把下拉提示條滾到視野范圍外。上拉加載條也位於iscroll容器內,但是它總是可以被用戶看見,所以對應的處理邏輯相對簡單。
- 不要在onScroll內調用scrollTo等移動滾動條的函數,因為onScroll內調用ScrollTo會導致繼續回調onScroll,如此往復像在打乒乓球,是不合理的。我的實現中,onScroll僅僅檢測用戶的觸屏行為(不處理慣性滑動),而onScrollEnd中才進行對應的邏輯處理或者發起scrollTo,而scrollTo觸發的是慣性滑動(isTouching=false),因而又不會造成onScroll的困擾。
- 點擊某一行會跳轉到MsgDetailPage組件,這是通過注冊onClick事件回調,並通過this.context.router操作react-router的路由實現的切換。
- 如果iscroll內元素太少沒有產生滾動條,那么會影響上述的效果實現邏輯。因此,我給<ul>元素設置了min-height:150%的高度,也就是最小溢出iscroll容器50%,保證滾動條總是存在,並且刷新提示條 有足夠的滾動范圍逃離用戶視線。
- 如果你在手機瀏覽器里上下拖拽,有時候會發現頁面整體在移動,而不是滾動條滾動。為了解決這個問題,我在react的根容器里,捕獲了body的touchmove事件,調用了preventDefault()阻止了瀏覽器默認行為。
必須注意,所有的網絡請求都是模擬的,並沒有動態的后端計算。
本文實現了非常有意思的動畫效果,也非常實用。
另外,第3個組件『留言提交頁』因為精力原因,不打算繼續寫完了。
當前訪問路徑如果是:列表頁 -> 詳情頁 -> 返回列表頁,會發現列表頁內容重新刷新了,滾動條也沒有停留在原先的位置上。這是因為每次路由切換,都是重新分配一個component對象進行重新渲染,所以狀態沒有保存,我當然可以在跳轉詳情頁之前把列表頁的狀態保存到一個全局變量里或者localStorage里,但是這畢竟比較麻煩。
為了實現狀態保存,redux就是在做類似的框架級支持,所以我可能接下來真的要學學redux了,學無止境,太可怕!