├── app.js ├── pages ├── index │ ├── index.wxss │ ├── index.wxml │ └── index.js └── myCoupon │ ├── myCoupon.json │ ├── myCoupon.wxml │ ├── myCoupon.wxss │ └── myCoupon.js ├── assets └── image │ ├── coupon_item_bg.png │ ├── coupon_item_icon.png │ ├── coupon_item_icon_no.png │ ├── coupon_item_icon_used.png │ └── coupon_item_icon_default.png ├── app.wxss ├── app.json ├── README.md ├── utils └── util.js ├── project.config.json └── LICENSE /app.js: -------------------------------------------------------------------------------- 1 | //app.js -------------------------------------------------------------------------------- /pages/index/index.wxss: -------------------------------------------------------------------------------- 1 | /**index.wxss**/ -------------------------------------------------------------------------------- /pages/myCoupon/myCoupon.json: -------------------------------------------------------------------------------- 1 | { 2 | "onReachBottomDistance": 0, 3 | "navigationBarTitleText": "我的优惠券" 4 | } -------------------------------------------------------------------------------- /assets/image/coupon_item_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZhipingYang/WX_MultiTabList/HEAD/assets/image/coupon_item_bg.png -------------------------------------------------------------------------------- /assets/image/coupon_item_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZhipingYang/WX_MultiTabList/HEAD/assets/image/coupon_item_icon.png -------------------------------------------------------------------------------- /assets/image/coupon_item_icon_no.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZhipingYang/WX_MultiTabList/HEAD/assets/image/coupon_item_icon_no.png -------------------------------------------------------------------------------- /assets/image/coupon_item_icon_used.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZhipingYang/WX_MultiTabList/HEAD/assets/image/coupon_item_icon_used.png -------------------------------------------------------------------------------- /assets/image/coupon_item_icon_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZhipingYang/WX_MultiTabList/HEAD/assets/image/coupon_item_icon_default.png -------------------------------------------------------------------------------- /pages/index/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{motto}} 5 | 6 | 7 | -------------------------------------------------------------------------------- /app.wxss: -------------------------------------------------------------------------------- 1 | /**app.wxss**/ 2 | .container { 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: space-between; 8 | padding: 200rpx 0; 9 | box-sizing: border-box; 10 | } 11 | -------------------------------------------------------------------------------- /pages/index/index.js: -------------------------------------------------------------------------------- 1 | //index.js 2 | //获取应用实例 3 | const app = getApp() 4 | 5 | Page({ 6 | data: { 7 | motto: '点击查看', 8 | }, 9 | //事件处理函数 10 | goto_multi_list(e) { 11 | wx.navigateTo({ 12 | url: '../myCoupon/myCoupon', 13 | }) 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "pages":[ 3 | "pages/index/index", 4 | "pages/myCoupon/myCoupon" 5 | ], 6 | "window":{ 7 | "backgroundTextStyle":"light", 8 | "navigationBarBackgroundColor": "#fff", 9 | "navigationBarTitleText": "WeChat", 10 | "navigationBarTextStyle":"black" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MultiTabList 2 | 3 | 微信小程序,多个Tab列表的上下拉刷新方案 4 | 5 | > 由于顶部多Tab视图,原生的下拉刷新不适合(用小程序原生下拉交互会很奇怪,更像是全局刷新而不是当前list刷新) 6 | 7 | ![gif_loading](https://user-images.githubusercontent.com/9360037/40223237-ed087906-5ab4-11e8-8890-e69fa168fb0a.gif) 8 | 9 | ### 支持功能 10 | 11 | - [x] 等间距Tabs展示 12 | - [ ] 顶部多个Tab可滚动展示 13 | - [x] 左右滑动手势切换List 14 | - [x] 网络错误、数据空白占位提示 15 | - [x] 刷新 & 分页懒加载 16 | - [x] 上拉刷新(三个state) 17 | -------------------------------------------------------------------------------- /utils/util.js: -------------------------------------------------------------------------------- 1 | const formatTime = date => { 2 | const year = date.getFullYear() 3 | const month = date.getMonth() + 1 4 | const day = date.getDate() 5 | const hour = date.getHours() 6 | const minute = date.getMinutes() 7 | const second = date.getSeconds() 8 | 9 | return [year, month, day].map(formatNumber).join('/') + ' ' + [hour, minute, second].map(formatNumber).join(':') 10 | } 11 | 12 | const formatNumber = n => { 13 | n = n.toString() 14 | return n[1] ? n : '0' + n 15 | } 16 | 17 | module.exports = { 18 | formatTime: formatTime 19 | } 20 | -------------------------------------------------------------------------------- /project.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "项目配置文件。", 3 | "setting": { 4 | "urlCheck": true, 5 | "es6": true, 6 | "postcss": true, 7 | "minified": true, 8 | "newFeature": true 9 | }, 10 | "compileType": "miniprogram", 11 | "libVersion": "1.9.94", 12 | "appid": "touristappid", 13 | "projectname": "MultiTapList", 14 | "condition": { 15 | "search": { 16 | "current": -1, 17 | "list": [] 18 | }, 19 | "conversation": { 20 | "current": -1, 21 | "list": [] 22 | }, 23 | "plugin": { 24 | "current": -1, 25 | "list": [] 26 | }, 27 | "game": { 28 | "currentL": -1, 29 | "list": [] 30 | }, 31 | "miniprogram": { 32 | "current": 0, 33 | "list": [ 34 | { 35 | "id": -1, 36 | "name": "list", 37 | "pathName": "pages/myCoupon/myCoupon" 38 | } 39 | ] 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 穷端-杨 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pages/myCoupon/myCoupon.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | {{item.title}} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {{item.title}} 28 | 到期时间:{{item.date}} 29 | 30 | 31 | 32 | 33 | 34 | 35 | {{item.price}} 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /pages/myCoupon/myCoupon.wxss: -------------------------------------------------------------------------------- 1 | .stv-container { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | .withAnimate { 11 | transition: all 100ms ease; 12 | -webkit-transform: translate3d(0, 0, 0); 13 | -moz-transform: translate3d(0, 0, 0); 14 | -ms-transform: translate3d(0, 0, 0); 15 | transform: translate3d(0, 0, 0); 16 | -webkit-backface-visibility: hidden; 17 | -moz-backface-visibility: hidden; 18 | -ms-backface-visibility: hidden; 19 | backface-visibility: hidden; 20 | -webkit-perspective: 1000; 21 | -moz-perspective: 1000; 22 | -ms-perspective: 1000; 23 | perspective: 1000; 24 | } 25 | .stv-container .tab-bar { 26 | position: relative; 27 | display: flex; 28 | font-size: 30rpx; 29 | color: #666666; 30 | background-color: white; 31 | border-top: 0.5rpx #dcdcdc solid; 32 | border-bottom: 0.5rpx #dcdcdc solid; 33 | } 34 | .stv-container .tab-bar .tab-active { 35 | color: #2d80ff; 36 | } 37 | .stv-container .tab-bar .tab { 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | padding-top: 16rpx; 42 | padding-bottom: 20rpx; 43 | } 44 | .stv-container .tab-bar .under-line { 45 | position: absolute; 46 | bottom: 0; 47 | height: 6rpx; 48 | background-color: #2d80ff; 49 | } 50 | .stv-container .scroll-view { 51 | position: relative; 52 | width: 100%; 53 | flex: 1; 54 | background: #e7eaef; 55 | } 56 | .stv-container .scroll-view .scroll-view-wrapper { 57 | position: absolute; 58 | top: 0; 59 | bottom: 0; 60 | display: flex; 61 | } 62 | .stv-container .scroll-view .scroll-view-wrapper .one-scene { 63 | height: 100%; 64 | } 65 | 66 | /* itemView */ 67 | .item_view { 68 | border-bottom: 1rpx solid #dcdcdc; 69 | margin: 10rpx 40rpx 0rpx 40rpx; 70 | height: 120rpx; 71 | display: flex; 72 | align-items: center; 73 | background-color: white; 74 | } 75 | .item_view .item_back_image { 76 | margin-left: -20rpx; 77 | height: 100%; 78 | width: 20rpx; 79 | } 80 | .item_view .item_image { 81 | padding-left: 20rpx; 82 | width: 80rpx; 83 | height: 80rpx; 84 | } 85 | .item_view .item_flex-clomn { 86 | padding-left: 30rpx; 87 | display: flex; 88 | flex-direction: column; 89 | justify-content: center; 90 | height: 100%; 91 | } 92 | .item_view .item_title{ 93 | font-size: 25rpx; 94 | color: darkgray; 95 | } 96 | .item_view .item_date { 97 | font-size: 20rpx; 98 | color: lightgray; 99 | margin-top: 10rpx; 100 | } 101 | .item_view .item_price_contain { 102 | margin-left: auto; 103 | min-width: 120rpx; 104 | height: 100%; 105 | position: relative; 106 | margin-right: -10rpx; 107 | } 108 | .item_view .item_price_contain image { 109 | position: absolute; 110 | width: 100%; 111 | height: 100%; 112 | } 113 | .item_view .item_price { 114 | color: white; 115 | font-size: 40rpx; 116 | position: relative; 117 | height: 100%; 118 | margin: 0rpx 15rpx 0rpx 10rpx; 119 | text-align: center; 120 | line-height: 120rpx; 121 | } 122 | 123 | .placeholder { 124 | display: inline-flex; 125 | text-align: center; 126 | align-items: center; 127 | justify-content: center; 128 | height: 90%; 129 | width: 100%; 130 | font-size: 40rpx; 131 | color: gray; 132 | } 133 | 134 | .loading_more { 135 | display: inline-flex; 136 | text-align: center; 137 | align-items: center; 138 | justify-content: center; 139 | height: 80rpx; 140 | width: 100%; 141 | font-size: 30rpx; 142 | color: lightgray; 143 | } -------------------------------------------------------------------------------- /pages/myCoupon/myCoupon.js: -------------------------------------------------------------------------------- 1 | // pages/myCoupon/myCoupon.js 2 | 3 | let staticPageNumber = 20; 4 | 5 | // 分页 list 6 | class TabItem { 7 | constructor(title) { 8 | this.title = title; // 标题 9 | this.list = []; // 数据列表 10 | this.placeholder = "点击刷新"; // 占位提示(刷新、网络错误、空白) 11 | this.load_type = 0; // 0表示不显示,1表示加载中,2表示已加载全部 12 | } 13 | } 14 | 15 | // 数据item 16 | class ListItem { 17 | constructor() { 18 | this.image_url = "../../assets/image/coupon_item_icon.png"; 19 | this.price = ListItem.randomNumber(5, 100); 20 | this.title = this.price + "元红包"; 21 | this.date = ListItem.randomNumber(2017, 2020) + "-03-02"; 22 | } 23 | static randomNumber(min, max) { 24 | return parseInt(Math.random() * (max - min) + min); 25 | } 26 | } 27 | 28 | // 空白页tip 29 | function getEmptyTip(index) { 30 | return ["无可使用优惠券", "无已使用优惠券", "无已失效的优惠券"][index % 3]; 31 | } 32 | 33 | // 假数据 34 | function getFakeData(num = staticPageNumber) { 35 | var list = []; 36 | for (let i = 0; i < num; i++) { 37 | let item = new ListItem() 38 | list.push(item) 39 | } 40 | return list; 41 | } 42 | 43 | Page({ 44 | data: { 45 | tabs: [ 46 | new TabItem("可使用的"), 47 | new TabItem("已使用的"), 48 | new TabItem("已失效的") 49 | ], 50 | stv: { 51 | windowWidth: 0, 52 | lineWidth: 0, 53 | offset: 0, 54 | tStart: false 55 | }, 56 | activeTab: 0, 57 | }, 58 | 59 | onLoad: function (options) { 60 | try { 61 | let { tabs } = this.data; 62 | var res = wx.getSystemInfoSync() 63 | this.windowWidth = res.windowWidth; 64 | this.data.stv.lineWidth = res.windowWidth / this.data.tabs.length; 65 | this.data.stv.windowWidth = res.windowWidth; 66 | this.setData({ stv: this.data.stv }) 67 | this.tabsCount = tabs.length; 68 | } catch (e) { 69 | // 70 | } 71 | }, 72 | 73 | onShow: function () { 74 | this.loadCouponsAtIndexRefresh(); 75 | }, 76 | 77 | loadCouponsAtIndexRefresh(index = 0, isRefresh = true) { 78 | 79 | // loading 80 | wx.showLoading({ 81 | title: '加载中', 82 | }); 83 | 84 | // 显示加载更多 85 | if (!isRefresh) { 86 | // 已经加载全部,则不再请求 87 | let config = this.data.tabs[index]; 88 | 89 | // 已经全部加载完毕 90 | if (!config.load_type == 2) { 91 | return; 92 | } 93 | 94 | var tabs = this.data.tabs; 95 | tabs[index].load_type = 1; 96 | this.setData({ 97 | tabs: tabs 98 | }) 99 | } 100 | 101 | setTimeout(() => { 102 | let res = { 103 | data: { code: 1 } 104 | }; 105 | 106 | // fake 107 | res.list = getFakeData(Math.random() > 0.5 ? 6 : staticPageNumber); 108 | 109 | wx.hideLoading(); 110 | 111 | let that = this; 112 | let item = that.data.tabs[index]; 113 | var tips = item.placeholder; 114 | var list = item.list; 115 | 116 | // 请求成功 117 | if (res.data.code == 1) { 118 | if (res.list && res.list.length > 0) { 119 | if (isRefresh) { 120 | list = res.list; 121 | } else { 122 | list.push(...res.list); 123 | } 124 | 125 | // 加载更多 126 | var tabs = this.data.tabs; 127 | tabs[index].load_type = res.list.length < staticPageNumber ? 2 : 0; 128 | tabs[index].list = list; 129 | 130 | that.setData({ 131 | tabs: tabs 132 | }) 133 | return; 134 | } else { 135 | tips = getEmptyTip(index); 136 | } 137 | } else { 138 | tips = res.msg.length > 0 ? res.msg : "网络错误"; 139 | } 140 | tips += " 点击刷新"; 141 | 142 | var tabs = this.data.tabs; 143 | tabs[index].placeholder = tips; 144 | 145 | that.setData({ 146 | tabs: tabs 147 | }) 148 | 149 | }, 600); 150 | }, 151 | 152 | // 加载更多 153 | loadMore(e) { 154 | let currentIndex = e.currentTarget.dataset.index; 155 | let currentTab = this.data.tabs[currentIndex]; 156 | if (currentTab.list.length > 0 && currentTab.load_type != 2) { 157 | this.loadCouponsAtIndexRefresh(currentIndex, false); 158 | } 159 | }, 160 | 161 | // 刷新 162 | refresh(e) { 163 | let currentIndex = e.currentTarget.dataset.index; 164 | let currentTab = this.data.tabs[currentIndex]; 165 | if (currentTab.list.length <= 0) { 166 | this.loadCouponsAtIndexRefresh(currentIndex); 167 | } 168 | }, 169 | 170 | // 手势开始 171 | handlerStart(e) { 172 | let { clientX, clientY } = e.touches[0]; 173 | this.startX = clientX; 174 | this.tapStartX = clientX; 175 | this.tapStartY = clientY; 176 | this.data.stv.tStart = true; 177 | this.tapStartTime = e.timeStamp; 178 | this.setData({ stv: this.data.stv }) 179 | }, 180 | 181 | // 手势移动 182 | handlerMove(e) { 183 | let { clientX, clientY } = e.touches[0]; 184 | let { stv } = this.data; 185 | let offsetX = this.startX - clientX; 186 | this.startX = clientX; 187 | stv.offset += offsetX; 188 | if (stv.offset <= 0) { 189 | stv.offset = 0; 190 | } else if (stv.offset >= stv.windowWidth * (this.tabsCount - 1)) { 191 | stv.offset = stv.windowWidth * (this.tabsCount - 1); 192 | } 193 | this.setData({ stv: stv }); 194 | }, 195 | 196 | // 手势取消 197 | handlerCancel(e) { 198 | 199 | }, 200 | 201 | // 滑动手势完成 202 | handlerEnd(e) { 203 | let { clientX, clientY } = e.changedTouches[0]; 204 | // 如果是点击手势,则屏蔽当前手势的事件处理 205 | if (Math.abs(this.tapStartX - clientX) < 1 && Math.abs(this.tapStartY - clientY) < 1) { 206 | return; 207 | } 208 | // 阻止干预scrollview的上下滑动体验 209 | if (Math.abs(this.data.stv.offset - 0) < 1 || Math.abs(this.data.stv.offset - this.data.windowWidth) < 1) { 210 | return; 211 | } 212 | let endTime = e.timeStamp; 213 | let { tabs, stv, activeTab } = this.data; 214 | let { offset, windowWidth } = stv; 215 | 216 | //快速滑动 217 | if (endTime - this.tapStartTime <= 300) { 218 | //向左 219 | if (Math.abs(this.tapStartY - clientY) < 50) { 220 | if (this.tapStartX - clientX > 5) { 221 | if (activeTab < this.tabsCount - 1) { 222 | let page = ++activeTab; 223 | this.reloadPageIfEmpty(page); 224 | this.setData({ activeTab: page }) 225 | } 226 | } else { 227 | if (activeTab > 0) { 228 | let page = --activeTab; 229 | this.reloadPageIfEmpty(page); 230 | this.setData({ activeTab: page }) 231 | } 232 | } 233 | stv.offset = stv.windowWidth * activeTab; 234 | } else { 235 | //快速滑动 但是Y距离大于50 所以用户是左右滚动 236 | let page = Math.round(offset / windowWidth); 237 | if (activeTab != page) { 238 | this.setData({ activeTab: page }) 239 | this.reloadPageIfEmpty(page); 240 | } 241 | stv.offset = stv.windowWidth * page; 242 | } 243 | } else { 244 | let page = Math.round(offset / windowWidth); 245 | 246 | if (activeTab != page) { 247 | this.setData({ activeTab: page }) 248 | this.reloadPageIfEmpty(page); 249 | } 250 | stv.offset = stv.windowWidth * page; 251 | } 252 | 253 | stv.tStart = false; 254 | this.setData({ stv: this.data.stv }) 255 | }, 256 | 257 | // item点击 258 | itemTap(e) { 259 | console.log(e); 260 | }, 261 | 262 | // 更新选中的page 263 | updateSelectedPage(page) { 264 | // 屏蔽重复选中 265 | if (this.data.activeTab == page) { 266 | return; 267 | } 268 | let { tabs, stv, activeTab } = this.data; 269 | activeTab = page; 270 | this.setData({ activeTab: activeTab }) 271 | stv.offset = stv.windowWidth * activeTab; 272 | this.setData({ stv: this.data.stv }) 273 | this.reloadPageIfEmpty(page); 274 | }, 275 | 276 | reloadPageIfEmpty(page) { 277 | // 重新请求 278 | if (this.data.tabs[page].list.length <= 0) { 279 | this.loadCouponsAtIndexRefresh(page); 280 | } 281 | }, 282 | 283 | // item view 点击 284 | handlerTabTap(e) { 285 | this.updateSelectedPage(e.currentTarget.dataset.index); 286 | }, 287 | 288 | /** 289 | * 用户点击右上角分享 290 | */ 291 | onShareAppMessage: function () { 292 | return { 293 | title: `准确极速、支持全国`, 294 | desc: '准确极速、覆盖全国、1.4亿车主都在用', 295 | path: '/pages/myCoupon/myCoupon' 296 | } 297 | }, 298 | }) --------------------------------------------------------------------------------