一、摘要
tab欄(標簽切換欄)是app中常見的一種交互方式,它可以承載更多的內容,同時又兼顧友好體驗的優點。但在小程序中,官方並沒有為咱們提供現成的組件。因此我們程序員展現才藝的時候到了(其實市面上的ui庫也做了這個組件)。今天咱們就來實現一個對用戶更加友好的tab欄,讓用戶“一點”就停不下來,起到解壓的功效~~!
廢話不多說,先上效果圖。
不瞞您說,這東西我能點一天^^。言歸正傳,由於tab欄用的地方很多,所以需要封裝成組件,因此沒有開發或者沒用過組件的同志請瞧一瞧官方文檔。我之前也寫過一篇組件開發的教程,有興趣的可以點一下。
二、正文
為了照顧新手,我會一步步分析整個實現流程。不僅僅是分析代碼,思想才是程序的靈魂,而一個程序員從初級進階的過程也正是從代碼到思想的轉變。
1.預期與實現思路分析
根據上面的效果圖,咱們可以分析出一下幾點預期:
- 首先咱得支持滾動效果,不支持滾動那還玩個屁,畢竟手機屏幕並不是無限寬的,而我們需要的tab頁卻是無限多的。
- 內容部分必須是自適應的,因為每一項的文字個數並不是固定的。
- 作為組件,咱得滿足閉開原則,即:需要外部修改的部分對外提供接口,不許外部修改的部分禁止訪問和修改。
- 需要支持多種主題,在不同的項目中使用不同的主題樣式。
- 作為組件,咱得滿足最小功能原則,即:一個組件只干一件具體的事情。
根據以上預期,可以分析出實現思路如下:
- 由於需要支持滾動效果,所以wxml中可以使用現成的scroll-view組件去實現。
- 由於內部是自適應,所以不能把寬度寫死。而且底部的“條塊”的長度也是自適應的。這是整個實現過程的難點,我先劇透一下,這里需要使用小程序提供的dom操作相關api。不熟悉的同學請點這里。
- 這一點很簡單,就是要時刻提醒自己,不必開放的就不要畫蛇添足的去寫接口了。
- 主題切換無非就是css樣式的變化。由於小程序不支持動態插入和操作dom(最多讓你獲取一下dom的屬性),所以主題的變化不能設計wxml結構的變化。這里我們只能笨重的使用wx:if指令去顯示和隱藏某些元素了,不過本次教程不涉及這個。
- 要滿足第五點,就只能做tab欄的切換相關東西了,不要把tab欄下面的切換相關的功能也做了。如果你做了,那么它的壞處顯而易見。首先是組件會變得更復雜(代碼層面),其次使用起來會非常局限(你怎么不把一個頁面作為一個組件吶,我看你怎么用)。
這些分析是有必要的,它將為我們后面的一些工作其指導作用,防止我們在編碼的過程中迷失自我。下面先從wxml的編寫開始。
2.wxml文件的編寫
一下是我們wxml的基本骨架,最外層用scroll-view組件,內容部分再包一層view,這樣有利於我們后面布局。
<scroll-view> <view> 內容部分 </view> </scroll-view>
由於tab欄的項數是不固定的,而且需要組件外傳入。所以我們使用wx:if指令完成每一項的渲染,而且組件外需要傳入一個數組。編寫后的代碼如下。
<scroll-view class='component'> <view class='content'> <view data-index="{{ index }}" wx:for="{{ items }}" wx:key="{{ index }}"> <text class='text'>{{ item }}</text> </view> </view> </scroll-view>
相信這一步只要有小程序開發基礎的都能看懂,我順便為所有的結點加上了類名,后面寫樣式需要用到。注意:組件中不推薦使用標簽及子類選擇器,所有在需要寫樣式的結點上都加上類名,官方推薦使用類選擇器。這一步循環后需要加上 wx:key="{{ index }} 以及 data-index="{{ index }}" 。因為我們的程序需要明確知道切換的每一項,並且在切換到不同項的時候做出相應的操作,不定義一個自定義數據index,后面的工作無法展開。
這樣tab欄的主體wxml就寫完了,不過我們好像還少了個底部“條塊”的代碼。其實當初我也是覺得底部“條塊”用 border-bottom:1px solid #666 之類的css樣式實現不就可以了嗎?其實認真觀察就會發現,底部“條塊”是帶動畫效果的,並不是一切換就里馬到文字下方,如果是這樣我們大可給text或者view設置一個底部邊框,這樣一來我們的教程就結束了。所有為了實現動畫效果,我們需要單獨給個view去作為這個“條塊”,並且在css中給它添加動畫效果。
這里打個岔子,因為在編寫組件的過程中,很多樣式代碼都不能在wxss文件中寫死,這樣組件就毫無擴展性可言,就是去了組件的意義。那么怎么把樣式給寫活吶(又不能在wxss中寫邏輯代碼)?實現方式有兩種:1.通過動態改變元素的class;2.通過動態改變元素的style屬性。為了更精細的控制樣式,我們這里采用第二種方式(這樣寫會讓dom渲染時間增加)。
下面是wxml文件的完整形態。
<scroll-view class='component cus' scroll-x="{{ isScroll }}" style='{{ scrollStyle }}'> <view class='content'> <view class='item' data-cus="{{ dataCus[index] }}" data-index="{{ index }}" wx:for="{{ items }}" wx:key="{{ index }}" style='min-width: {{ itemWidth }}rpx; height: {{ height }}rpx' catchtap='onItemTap' > <text class='text' style='color: {{ mSelected == index ? selectColor : textColor }};font-size: {{ textSize }}rpx;'>{{ item }}</text> </view> <view class='bottom-bar {{ theme == "smallBar" ? "small" : "" }}' style='background-color: {{ selectColor }}; left: {{ left }}px; right: {{ right }}px; bottom: {{ bottom }}rpx; transition: {{ transition }}; border-shadow: 0rpx 0rpx 10rpx 0rpx {{ selectColor }};'></view> </view> </scroll-view>
可以看到里面動態綁定了很多變量,下面我們來一個個的介紹各變量的作用。
scroll-x="{{ isScroll }} 用於動態改變scroll-view組件的滾動,因為我們需要實現當元素小於5個的時候我們不應該讓tab欄滾動,因為這個時候的元素很少,不滾動才是最優的用戶體驗。
data-index="{{ index }}" 用於唯一標識每一項,方便后面對每一項進行操作
wx:for="{{ items }}" 用於渲染列表,需要組件外傳入,因為tab組件在被使用前並不知道每一項的具體內容,當然你大可在組件里定義個數組,這樣的組件就沒有一樣,只能在一種場合下使用。
style='min-width: {{ itemWidth }}rpx; height: {{ height }}rpx' 這里的兩個變量用於控制每一項最外層view的樣式。其中itemWidth只在組件內部使用,因為對於組件外部來說,我們更希望這個tab組件能根據我們傳入的數據自適應的改變寬度。而height需要對外提供接口,因為根據不同的使用場景,我們可能需要不同高度的tab組件來滿足我們的需求。
style='color: {{ mSelected == index ? selectColor : textColor }};font-size: {{ textSize }}rpx;' mSelected只在組件內部使用表示選中的某一項,當該項被選中后需要改變顏色,即:當mSelected與當前項的索引index相等時才表示選中。selectColor與textColor都需要外部提供。這樣我們就實現了選中改變文字顏色的效果。
{{ theme == "smallBar" ? "small" : "" }} 這里使用到了第一種動態改變樣式的方式,根據主題來改變類名。
style='background-color: {{ selectColor }}; left: {{ left }}px; right: {{ right }}px; bottom: {{ bottom }}rpx; transition: {{ transition }}; border-shadow: 0rpx 0rpx 10rpx 0rpx {{ selectColor }};' 這里是實現“條塊”動畫的基礎,可以通過left和right屬性來改變“條塊”的位置以及寬度,是不是很神奇。在js部分我們就是通過操作left和right變量來實現我們看到的動畫效果。
3.wxss文件的編寫
由於我們大部分樣式都是動態的,所以必須在wxml中寫。因此wxss中的代碼就很少,只需要寫一些靜態的樣式。一下是完整代碼,由於比較簡單,就不過多的解釋了。
.component { background-color: white; white-space: nowrap; box-sizing: border-box; } .content { position: relative; } .item { display: inline-flex; align-items: center; justify-content: center; padding: 0 30rpx; } .text { transition: color 0.2s } .bottom-bar { position: absolute; height: 2px; border-radius: 2px } .small { height: 4px; border-radius: 2px; }
需要注意的是,底部“條塊”使用了left和right屬性,因此需要使用相對定位。由於我們需要實現滾動效果,所以scroll-view的樣式部分我們還需要加一條 white-space: nowrap; 屬性來防止自動換行(按理來說,既然設置了橫向滾動,scroll-view組件就應該給我們自動加上這條屬性),反正這應該算是scroll-view組件的一個bug了,有興趣的同學可以看下我的這篇博客。
4.js文件的編寫
重頭戲來了。首先來看一下完整的js代碼,后面我再一點點講解。
1 const themes = { 2 smallBar: 'smallBar' 3 } 4 5 Component({ 6 /** 7 * 組件的屬性列表 8 */ 9 properties: { 10 items: { 11 type: Array, 12 value: ['item1', 'item2', 'item3', 'item4'], 13 observer: function (newVal) { 14 if (newVal && newVal.length < 5) { 15 this.setData({ 16 itemWidth: (750 / newVal.length) - 60 17 }) 18 } 19 } 20 }, 21 height: { 22 type: String, 23 value: '120' 24 }, 25 textColor: { 26 type: String, 27 value: '#666666' 28 }, 29 textSize: { 30 type: String, 31 value: '28' 32 }, 33 selectColor: { 34 type: String, 35 value: '#FE9036' 36 }, 37 selected: { 38 type: String, 39 value: '0', 40 observer: function (newVal) { 41 this.setData({ 42 mSelected: newVal 43 }) 44 } 45 }, 46 theme: { 47 type: String, 48 value: 'default', 49 observer: function (newVal) { 50 if (this.data.theme == themes.smallBar) { 51 this.setData({ 52 bottom: this.data.height / 2 - this.data.textSize - 8, 53 scrollStyle: '' 54 }) 55 } 56 } 57 }, 58 dataCus: { 59 type: Array, 60 value: '', 61 observer: function (newVal) { 62 this.setData({ 63 mDataCus: newVal 64 }); 65 } 66 } 67 }, 68 69 /** 70 * 組件的初始數據 71 */ 72 data: { 73 itemWidth: 128, 74 isScroll: true, 75 scrollStyle: 'border-bottom: 1px solid #e5e5e5;', 76 left: '0', 77 right: '750', 78 bottom: '0', 79 mSelected: '0', 80 lastIndex: 0, 81 transition: 'left 0.5s, right 0.2s', 82 windowWidth: 375, 83 domData: [], 84 textDomData: [], 85 mDataCus: [] 86 }, 87 88 externalClasses: ['cus'], 89 90 /** 91 * 組件的方法列表 92 */ 93 methods: { 94 barLeft: function(index, dom) { 95 let that = this; 96 this.setData({ 97 left: dom[index].left 98 }) 99 }, 100 barRight: function (index, dom) { 101 let that = this; 102 this.setData({ 103 right: that.data.windowWidth - dom[index].right, 104 }) 105 }, 106 onItemTap: function(e) { 107 const index = e.currentTarget.dataset.index; 108 let str = this.data.lastIndex < index ? 'left 0.5s, right 0.2s' : 'left 0.2s, right 0.5s'; 109 this.setData({ 110 transition: str, 111 lastIndex: index, 112 mSelected: index 113 }) 114 if (this.data.theme == themes.smallBar) { 115 this.barLeft(index, this.data.textDomData); 116 this.barRight(index, this.data.textDomData); 117 } else { 118 this.barLeft(index, this.data.domData); 119 this.barRight(index, this.data.domData); 120 } 121 this.triggerEvent('itemtap', e, { bubbles: true }); 122 } 123 }, 124 125 lifetimes: { 126 ready: function () { 127 let that = this; 128 const sysInfo = wx.getSystemInfoSync(); 129 this.setData({ 130 windowWidth: sysInfo.screenWidth 131 }) 132 const query = this.createSelectorQuery(); 133 query.in(this).selectAll('.item').fields({ 134 dataset: true, 135 rect: true, 136 size: true 137 }, function (res) { 138 that.setData({ 139 domData: res, 140 }) 141 that.barLeft(that.data.mSelected, that.data.domData); 142 that.barRight(that.data.mSelected, that.data.domData); 143 // console.log(res) 144 }).exec() 145 query.in(this).selectAll('.text').fields({ 146 dataset: true, 147 rect: true, 148 size: true 149 }, function (res) { 150 that.setData({ 151 textDomData: res, 152 }) 153 if (that.data.theme == themes.smallBar) { 154 that.barLeft(that.data.mSelected, that.data.textDomData); 155 that.barRight(that.data.mSelected, that.data.textDomData); 156 } 157 console.log(res) 158 }).exec() 159 }, 160 }, 161 })
properties字段中的變量都是對外提供的接口。這個字段里面我們着重看一下items字段。
items: { type: Array, value: ['item1', 'item2', 'item3', 'item4'], observer: function (newVal) { if (newVal && newVal.length < 5) { this.setData({ itemWidth: (750 / newVal.length) - 60 }) } } },
我們把該字段的類型定義為了數組,因此組件外需要傳入一個數組。在外界沒有傳入任何數值的情況下我們也要顯示一個完整的tab欄啊,所以默認值是有必要的,盡管使用的時候一定會覆蓋我們的默認值。 observer 這個屬性用得可能不是很多,大家可能有些陌生。仔細看過官方文檔的同學應該知道,該屬性用於當items字段在組件外被賦值或者被改變的情況下觸發回調函數,其中回調函數可以接受newVal這樣的新值,也可以接受oldVal這樣的老值。我們需要根據傳入的數組動態的設置每一項的寬度,在講解wxml的時候我們知道 itemWidth 變量是用來控制每一項的寬度的。這里用if判斷當數組長度小於5時就會設置每一項的寬度,而這個寬度就是通過750除以數組長度來的,最后我們還要減去每一項的左右padding,因為padding是不計入寬度的。這樣以來,當數組的元素個數低於五個的時候,tab組件就會將屏幕寬度等分,這樣就不會出現滾動效果。當數組的元素個數超過5,那么我們就給一個默認值,當然我們在wxml中設置的是 min-width 屬性,所以不用擔心設置了寬度就會造成寬度不自適應的情況。
因為底部“條塊”需要知道當前選項的位置,這樣才能滾動到選中項的下面。所以要實現這個效果,以及當前處於第幾項以及該項的位置。小程序雖然不支持dom操作,但支持獲取dom屬性。
lifetimes: { ready: function () { let that = this; const sysInfo = wx.getSystemInfoSync(); this.setData({ windowWidth: sysInfo.screenWidth }) const query = this.createSelectorQuery(); query.in(this).selectAll('.item').fields({ dataset: true, rect: true, size: true }, function (res) { that.setData({ domData: res, }) that.barLeft(that.data.mSelected, that.data.domData); that.barRight(that.data.mSelected, that.data.domData); // console.log(res) }).exec() query.in(this).selectAll('.text').fields({ dataset: true, rect: true, size: true }, function (res) { that.setData({ textDomData: res, }) if (that.data.theme == themes.smallBar) { that.barLeft(that.data.mSelected, that.data.textDomData); that.barRight(that.data.mSelected, that.data.textDomData); } console.log(res) }).exec() }, },
這段代碼是在ready生命周期中進行的,因為只有組件在ready這個生命周期,我們才能獲取dom。這個生命周期是在dom渲染完畢后執行的。首先我們通過 wx.getSystemInfoSync() 獲取系統的信息,里面包括我們需要的屏幕寬度。注意整個計算過程都是使用px作為單位,雖然我們知道每個設備的寬度固定為750rpx,但是px是不固定的。之后我們通過 this.createSelectorQuery(); 來查詢需要的dom結點(類似與jQuery)。首先查詢類名為item的所有元素,並且將數據保存到domData變量。由於在smallBar主題下,我們是根據文字寬度來定位底部“條塊”的,所有還需要獲取類名為text的所有結點信息,並將其保存到textDomData變量中。下面我們來看下獲取的dom數據的結構。
其中left正是該元素在父組件中距離父組件最左邊的距離以px為單位。對我們有用的就是left和right兩字段,這意味着我們知道了每一項的具體定位。至於當前的選項我們則通過點擊事件來獲取。下面是整個組件的核心代碼。
methods: { barLeft: function(index, dom) { let that = this; this.setData({ left: dom[index].left }) }, barRight: function (index, dom) { let that = this; this.setData({ right: that.data.windowWidth - dom[index].right, }) }, onItemTap: function(e) { const index = e.currentTarget.dataset.index; let str = this.data.lastIndex < index ? 'left 0.5s, right 0.2s' : 'left 0.2s, right 0.5s'; this.setData({ transition: str, lastIndex: index, mSelected: index }) if (this.data.theme == themes.smallBar) { this.barLeft(index, this.data.textDomData); this.barRight(index, this.data.textDomData); } else { this.barLeft(index, this.data.domData); this.barRight(index, this.data.domData); } this.triggerEvent('itemtap', e, { bubbles: true }); } },
這里定義了三個函數,其中 barLeft 和 barRight 分別完成設置底部“條塊”的left值和right值。需要特別說明一下,只要我們動態計算並設置了底部“條塊”的left和right屬性,那么底部“條塊”的位置大小在水平方向上就以及確定,而垂直方向上的位置大小都是固定寫死在css文件中的。這兩個函數都需要傳入當前選項的索引以及所有選項dom的位置信息。
onItemTap 方法綁定了每一項的點擊事件,可以查看wxml中的完整代碼。當選項被點擊后,它的索引可通過 e.currentTarget.dataset.index 獲取,因為我們在wxml中定義了一個自定義屬性。
至此我們的核心邏輯就實現完畢了,關鍵點在於獲取所有選項的位置信息以及當前選項的索引。有興趣的同學可以前往github查看源代碼。
三、結論
雖然這篇博文是以教程的形式寫的,但是我們還是有必要總結一下。
在寫程序的時候思想要走在編碼的前列,不要讓思想被具體代碼牽着鼻子走。要有一定的封裝思想,雖然ctrl+c,ctrl+v大法可以解決一切問題,但是這樣的代碼是無法維護和閱讀的。既然封裝,那就得考慮擴展性和閉開原則了。哪里開放,哪里閉合心里要有點逼數。可不可以擴展將影響到后續的修改。當一個極具挑戰的東西需要我們實現的時候,只需要抓住重點,分步展開,就會發現問題就變得簡單起來了。如果需要的步數太多,那也許是你簡單問題復雜化了。
四、寫在最后
如果你懶得寫,也可以嘗試一下使用博主封裝的小程序UI組件庫,里面包含了開發中常用的組件。希望各位老鐵多多提意見,也可以提交自己的組件。打了這么多字,你就不心疼一下博主?
掃描小程序碼,可查看效果。