1. 程式人生 > >微信小程序-tab標簽欄實現教程

微信小程序-tab標簽欄實現教程

位置信息 otto 程序開發 external The ogr tco 選項 em1

技術分享圖片

一、摘要

  tab欄(標簽切換欄)是app中常見的一種交互方式,它可以承載更多的內容,同時又兼顧友好體驗的優點。但在小程序中,官方並沒有為咱們提供現成的組件。因此我們程序員展現才藝的時候到了(其實市面上的ui庫也做了這個組件)。今天咱們就來實現一個對用戶更加友好的tab欄,讓用戶“一點”就停不下來,起到解壓的功效~~!

  廢話不多說,先上效果圖。

技術分享圖片

  不瞞您說,這東西我能點一天^^。言歸正傳,由於tab欄用的地方很多,所以需要封裝成組件,因此沒有開發或者沒用過組件的同誌請瞧一瞧官方文檔。我之前也寫過一篇組件開發的教程,有興趣的可以點一下。

二、正文

  為了照顧新手,我會一步步分析整個實現流程。不僅僅是分析代碼,思想才是程序的靈魂,而一個程序員從初級進階的過程也正是從代碼到思想的轉變。

1.預期與實現思路分析

  根據上面的效果圖,咱們可以分析出一下幾點預期:

  1. 首先咱得支持滾動效果,不支持滾動那還玩個屁,畢竟手機屏幕並不是無限寬的,而我們需要的tab頁卻是無限多的。
  2. 內容部分必須是自適應的,因為每一項的文字個數並不是固定的。
  3. 作為組件,咱得滿足閉開原則,即:需要外部修改的部分對外提供接口,不許外部修改的部分禁止訪問和修改。
  4. 需要支持多種主題,在不同的項目中使用不同的主題樣式。
  5. 作為組件,咱得滿足最小功能原則,即:一個組件只幹一件具體的事情。

  根據以上預期,可以分析出實現思路如下:

  1. 由於需要支持滾動效果,所以wxml中可以使用現成的scroll-view組件去實現。
  2. 由於內部是自適應,所以不能把寬度寫死。而且底部的“條塊”的長度也是自適應的。這是整個實現過程的難點,我先劇透一下,這裏需要使用小程序提供的dom操作相關api。不熟悉的同學請點這裏。
  3. 這一點很簡單,就是要時刻提醒自己,不必開放的就不要畫蛇添足的去寫接口了。
  4. 主題切換無非就是css樣式的變化。由於小程序不支持動態插入和操作dom(最多讓你獲取一下dom的屬性),所以主題的變化不能設計wxml結構的變化。這裏我們只能笨重的使用wx:if指令去顯示和隱藏某些元素了,不過本次教程不涉及這個。
  5. 要滿足第五點,就只能做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 });
    }
  },

  這裏定義了三個函數,其中 barLeftbarRight 分別完成設置底部“條塊”的left值和right值。需要特別說明一下,只要我們動態計算並設置了底部“條塊”的left和right屬性,那麽底部“條塊”的位置大小在水平方向上就以及確定,而垂直方向上的位置大小都是固定寫死在css文件中的。這兩個函數都需要傳入當前選項的索引以及所有選項dom的位置信息。

   onItemTap 方法綁定了每一項的點擊事件,可以查看wxml中的完整代碼。當選項被點擊後,它的索引可通過 e.currentTarget.dataset.index 獲取,因為我們在wxml中定義了一個自定義屬性。

  至此我們的核心邏輯就實現完畢了,關鍵點在於獲取所有選項的位置信息以及當前選項的索引。有興趣的同學可以前往github查看源代碼。

三、結論

  雖然這篇博文是以教程的形式寫的,但是我們還是有必要總結一下。

  在寫程序的時候思想要走在編碼的前列,不要讓思想被具體代碼牽著鼻子走。要有一定的封裝思想,雖然ctrl+c,ctrl+v大法可以解決一切問題,但是這樣的代碼是無法維護和閱讀的。既然封裝,那就得考慮擴展性和閉開原則了。哪裏開放,哪裏閉合心裏要有點逼數。可不可以擴展將影響到後續的修改。當一個極具挑戰的東西需要我們實現的時候,只需要抓住重點,分步展開,就會發現問題就變得簡單起來了。如果需要的步數太多,那也許是你簡單問題復雜化了。

四、寫在最後

  如果你懶得寫,也可以嘗試一下使用博主封裝的小程序UI組件庫,裏面包含了開發中常用的組件。希望各位老鐵多多提意見,也可以提交自己的組件。打了這麽多字,你就不心疼一下博主?

  GitHub地址>>

  掃描小程序碼,可查看效果。

  技術分享圖片

微信小程序-tab標簽欄實現教程