vue之better-scroll的封裝,包含下拉刷新,上拉加載功能及UI(核心為借鑒,我僅僅是給輪子套上了外胎...)


  先發原文作者、地址等信息。我把內容全部搬過來了,也可以去看原文。內容絕對是滿滿的干貨,給原作者點贊!(我添加的內容在轉載過來的后面,內容不多)

作者: ustbhuangyi 
鏈接:http://www.imooc.com/article/18232
來源:慕課網

  在我們日常的移動端項目開發中,處理滾動列表是再常見不過的需求了,以滴滴為例,可以是這樣豎向滾動的列表,如圖所示:

也可以是橫向滾動的導航欄,如圖所示:

可以打開“微信 —> 錢包—>滴滴出行”體驗效果。

我們在實現這類滾動功能的時候,會用到我寫的第三方庫,better-scroll。

什么是 better-scroll

better-scroll 是一個移動端滾動的解決方案,它是基於 iscroll 的重寫,它和 iscroll 的主要區別在這里。better-scroll 也很強大,不僅可以做普通的滾動列表,還可以做輪播圖、picker 等等。

better-scroll 的滾動原理

不少同學可能用過 better-scroll,我收到反饋最多的問題是:

我的 better-scroll 初始化了, 但是沒法滾動。

不能滾動是現象,我們得搞清楚這其中的根本原因。在這之前,我們先來看一下瀏覽器的滾動原理:
瀏覽器的滾動條大家都會遇到,當頁面內容的高度超過視口高度的時候,會出現縱向滾動條;當頁面內容的寬度超過視口寬度的時候,會出現橫向滾動條。也就是當我們的視口展示不下內容的時候,會通過滾動條的方式讓用戶滾動屏幕看到剩余的內容。

那么對於 better-scroll 也是一樣的道理,我們先來看一下 better-scroll 常見的 html 結構:

<div class="wrapper">
  <ul class="content">
    <li>...</li>
    <li>...</li>
    ...
  </ul>
</div>

為了更加直觀,我們再來看一張圖:

布局

綠色部分為 wrapper,也就是父容器,它會有固定的高度。黃色部分為 content,它是父容器的第一個子元素,它的高度會隨着內容的大小而撐高。那么,當 content 的高度不超過父容器的高度,是不能滾動的,而它一旦超過了父容器的高度,我們就可以滾動內容區了,這就是 better-scroll 的滾動原理。

那么,我們怎么初始化 better-scroll 呢,如果是上述 html 結構,那么初始化代碼如下:

import BScroll from 'better-scroll'
let wrapper = document.querySelector('.wrapper')
let scroll = new BScroll(wrapper, {})

better-scroll 對外暴露了一個 BScroll 的類,我們初始化只需要 new 一個類的實例即可。第一個參數就是我們 wrapper 的 DOM 對象,第二個是一些配置參數,具體參考 better-scroll 的文檔

better-scroll 的初始化時機很重要,因為它在初始化的時候,會計算父元素和子元素的高度和寬度,來決定是否可以縱向和橫向滾動。因此,我們在初始化它的時候,必須確保父元素和子元素的內容已經正確渲染了。如果子元素或者父元素 DOM 結構發生改變的時候,必須重新調用 scroll.refresh() 方法重新計算來確保滾動效果的正常。所以同學們反饋的 better-scroll 不能滾動的原因多半是初始化 better-scroll 的時機不對,或者是當 DOM 結構發送變化的時候並沒有重新計算 better-scroll。

better-scroll 遇見 Vue

相信很多同學對 Vue.js 都不陌生,當 better-scroll 遇見 Vue,會擦出怎樣的火花呢?

如何在 Vue 中使用 better-scroll

很多同學開始接觸使用 better-scroll 都是受到了我的一門教學課程——《Vue.js高仿餓了么外賣App》 的影響。在那門課程中,我們把 better-scroll 和 Vue 做了結合,實現了很多列表滾動的效果。在 Vue 中的使用方法如下:

<template>
  <div class="wrapper" ref="wrapper">
    <ul class="content">
      <li>...</li>
      <li>...</li>
      ...
    </ul>
  </div>
</template>
<script>
  import BScroll from 'better-scroll'
  export default {
    mounted() {
      this.$nextTick(() => {
        this.scroll = new Bscroll(this.$refs.wrapper, {})
      })
    }
  }
</script>

Vue.js 提供了我們一個獲取 DOM 對象的接口—— vm.$refs。在這里,我們通過了 this.$refs.wrapper 訪問到了這個 DOM 對象,並且我們在 mounted 這個鈎子函數里,this.$nextTick 的回調函數中初始化 better-scroll 。因為這個時候,wrapper 的 DOM 已經渲染了,我們可以正確計算它以及它內層 content 的高度,以確保滾動正常。

這里的 this.$nextTick 是一個異步函數,為了確保 DOM 已經渲染,感興趣的同學可以了解一下它的內部實現細節,底層用到了 MutationObserver 或者是 setTimeout(fn, 0)。其實我們在這里把 this.$nextTick 替換成 setTimeout(fn, 20) 也是可以的(20 ms 是一個經驗值,每一個 Tick 約為 17 ms),對用戶體驗而言都是無感知的。

異步數據的處理

在我們的實際工作中,列表的數據往往都是異步獲取的,因此我們初始化 better-scroll 的時機需要在數據獲取后,代碼如下:

<template>
  <div class="wrapper" ref="wrapper">
    <ul class="content">
      <li v-for="item in data">{{item}}</li>
    </ul>
  </div>
</template>
<script>
  import BScroll from 'better-scroll'
  export default {
    data() {
      return {
        data: []
      }
    },
    created() {
      requestData().then((res) => {
        this.data = res.data
        this.$nextTick(() => {
          this.scroll = new Bscroll(this.$refs.wrapper, {})
        })
      })
    }
  }
</script>

這里的 requestData 是偽代碼,作用就是發起一個 http 請求從服務端獲取數據,並且這個函數返回的是一個 promise(實際項目中我們可能會用 axios 或者 vue-resource)。我們獲取到數據的后,需要通過異步的方式再去初始化 better-scroll,因為 Vue 是數據驅動的, Vue 數據發生變化(this.data = res.data)到頁面重新渲染是一個異步的過程,我們的初始化時機是要在 DOM 重新渲染后,所以這里用到了 this.$nextTick,當然替換成 setTimeout(fn, 20) 也是可以的。

為什么這里在 created 這個鈎子函數里請求數據而不是放到 mounted 的鈎子函數里?因為 requestData 是發送一個網絡請求,這是一個異步過程,當拿到響應數據的時候,Vue 的 DOM 早就已經渲染好了,但是數據改變 —> DOM 重新渲染仍然是一個異步過程,所以即使在我們拿到數據后,也要異步初始化 better-scroll。

數據的動態更新

我們在實際開發中,除了數據異步獲取,還有一些場景可以動態更新列表中的數據,比如常見的下拉加載,上拉刷新等。比如我們用 better-scroll 配合 Vue 實現下拉加載功能,代碼如下:

<template>
  <div class="wrapper" ref="wrapper">
    <ul class="content">
      <li v-for="item in data">{{item}}</li>
    </ul>
    <div class="loading-wrapper"></div>
  </div>
</template>
<script>
  import BScroll from 'better-scroll'
  export default {
    data() {
      return {
        data: []
      }
    },
    created() {
      this.loadData()
    },
    methods: {
      loadData() {
        requestData().then((res) => {
          this.data = res.data.concat(this.data)
          this.$nextTick(() => {
            if (!this.scroll) {
              this.scroll = new Bscroll(this.$refs.wrapper, {})
              this.scroll.on('touchend', (pos) => {
                // 下拉動作
                if (pos.y > 50) {
                  this.loadData()
                }
              })
            } else {
              this.scroll.refresh()
            }
          })
        })
      }
    }
  }
</script>

這段代碼比之前稍微復雜一些, 當我們在滑動列表松開手指時候, better-scroll 會對外派發一個 touchend 事件,我們監聽了這個事件,並且判斷了 pos.y > 50(我們把這個行為定義成一次下拉的動作)。如果是下拉的話我們會重新請求數據,並且把新的數據和之前的 data 做一次 concat,也就更新了列表的數據,那么數據的改變就會映射到 DOM 的變化。需要注意的一點,這里我們對 this.scroll 做了判斷,如果沒有初始化過我們會通過 new BScroll 初始化,並且綁定一些事件,否則我們會調用 this.scroll.refresh 方法重新計算,來確保滾動效果的正常。

這里,我們就通過 better-scroll 配合 Vue,實現了列表的下拉刷新功能,上拉加載也是類似的套路,一切看上去都是 ok 的。但是,我們發現這里寫了大量命令式的代碼(這一點不是 Vue.js 推薦的),如果有很多類似滾動的組件,我們就需要寫很多類似的命令式且重復性的代碼,而且我們把數據請求和 better-scroll 也做了強耦合,這些對於一個追求編程逼格的人來說,就不 ok 了。

scroll 組件的抽象和封裝

因此,我們有強烈的需求抽象出來一個 scroll 組件,類似小程序的 scroll-view 組件,方便開發者的使用。

首先,我們要考慮的是 scroll 組件本質上就是一個可以滾動的列表組件,至於列表的 DOM 結構,只需要滿足 better-scroll 的 DOM 結構規范即可,具體用什么標簽,有哪些輔助節點(比如下拉刷新上拉加載的 loading 層),這些都不是 scroll 組件需要關心的。因此, scroll 組件的 DOM 結構十分簡單,如下所示:

<template>
  <div ref="wrapper">
    <slot></slot>
  </div>
</template>

這里我們用到了 Vue 的特殊元素—— slot 插槽,它可以滿足我們靈活定制列表 DOM 結構的需求。接下來我們來看看 JS 部分:

<script type="text/ecmascript-6">
  import BScroll from 'better-scroll'

  export default {
    props: {
      /**
       * 1 滾動的時候會派發scroll事件,會截流。
       * 2 滾動的時候實時派發scroll事件,不會截流。
       * 3 除了實時派發scroll事件,在swipe的情況下仍然能實時派發scroll事件
       */
      probeType: {
        type: Number,
        default: 1
      },
      /**
       * 點擊列表是否派發click事件
       */
      click: {
        type: Boolean,
        default: true
      },
      /**
       * 是否開啟橫向滾動
       */
      scrollX: {
        type: Boolean,
        default: false
      },
      /**
       * 是否派發滾動事件
       */
      listenScroll: {
        type: Boolean,
        default: false
      },
      /**
       * 列表的數據
       */
      data: {
        type: Array,
        default: null
      },
      /**
       * 是否派發滾動到底部的事件,用於上拉加載
       */
      pullup: {
        type: Boolean,
        default: false
      },
      /**
       * 是否派發頂部下拉的事件,用於下拉刷新
       */
      pulldown: {
        type: Boolean,
        default: false
      },
      /**
       * 是否派發列表滾動開始的事件
       */
      beforeScroll: {
        type: Boolean,
        default: false
      },
      /**
       * 當數據更新后,刷新scroll的延時。
       */
      refreshDelay: {
        type: Number,
        default: 20
      }
    },
    mounted() {
      // 保證在DOM渲染完畢后初始化better-scroll
      setTimeout(() => {
        this._initScroll()
      }, 20)
    },
    methods: {
      _initScroll() {
        if (!this.$refs.wrapper) {
          return
        }
        // better-scroll的初始化
        this.scroll = new BScroll(this.$refs.wrapper, {
          probeType: this.probeType,
          click: this.click,
          scrollX: this.scrollX
        })

        // 是否派發滾動事件
        if (this.listenScroll) {
          let me = this
          this.scroll.on('scroll', (pos) => {
            me.$emit('scroll', pos)
          })
        }

        // 是否派發滾動到底部事件,用於上拉加載
        if (this.pullup) {
          this.scroll.on('scrollEnd', () => {
            // 滾動到底部
            if (this.scroll.y <= (this.scroll.maxScrollY + 50)) {
              this.$emit('scrollToEnd')
            }
          })
        }

        // 是否派發頂部下拉事件,用於下拉刷新
        if (this.pulldown) {
          this.scroll.on('touchend', (pos) => {
            // 下拉動作
            if (pos.y > 50) {
              this.$emit('pulldown')
            }
          })
        }

        // 是否派發列表滾動開始的事件
        if (this.beforeScroll) {
          this.scroll.on('beforeScrollStart', () => {
            this.$emit('beforeScroll')
          })
        }
      },
      disable() {
        // 代理better-scroll的disable方法
        this.scroll && this.scroll.disable()
      },
      enable() {
        // 代理better-scroll的enable方法
        this.scroll &&this.scroll.enable()},
      refresh(){// 代理better-scroll的refresh方法this.scroll &&this.scroll.refresh()},
      scrollTo(){// 代理better-scroll的scrollTo方法this.scroll &&this.scroll.scrollTo.apply(this.scroll, arguments)},
      scrollToElement(){// 代理better-scroll的scrollToElement方法this.scroll &&this.scroll.scrollToElement.apply(this.scroll, arguments)}},
    watch:{// 監聽數據的變化,延時refreshDelay時間后調用refresh方法重新計算,保證滾動效果正常
      data(){
        setTimeout(()=>{this.refresh()},this.refreshDelay)}}}</script>

JS 部分實際上就是對 better-scroll 做一層 Vue 的封裝,通過 props 的形式,把一些對 better-scroll 定制化的控制權交給父組件;通過 methods 暴露的一些方法對 better-scroll 的方法做一層代理;通過 watch 傳入的 data,當 data 發生改變的時候,在適當的時機調用 refresh 方法重新計算 better-scroll 確保滾動效果正常,這里之所以要有一個 refreshDelay 的設置是考慮到如果我們對列表操作用到了 transition-group 做動畫效果,那么 DOM 的渲染完畢時間就是在動畫完成之后。

有了這一層 scroll 組件的封裝,我們來修改剛剛最復雜的代碼(假設我們已經全局注冊了 scroll 組件)。

<template>
  <scroll class="wrapper"
          :data="data"
          :pulldown="pulldown"
          @pulldown="loadData">
    <ul class="content">
      <li v-for="item in data">{{item}}</li>
    </ul>
    <div class="loading-wrapper"></div>
  </scroll>
</template>
<script>
  import BScroll from 'better-scroll'
  export default {
    data() {
      return {
        data: [],
        pulldown: true
      }
    },
    created() {
      this.loadData()
    },
    methods: {
      loadData() {
        requestData().then((res) => {
          this.data = res.data.concat(this.data)
        })
      }
    }
  }
</script>

可以很明顯的看到我們的 JS 部分精簡了非常多的代碼,沒有對 better-scroll 再做命令式的操作了,同時把數據請求和 better-scroll 也做了剝離,父組件只需要把數據 data 通過 prop 傳給 scroll 組件,就可以保證 scroll 組件的滾動效果。同時,如果想實現下拉刷新的功能,只需要通過 prop 把 pulldown 設置為 true,並且監聽 pulldown 的事件去做一些數據獲取並更新的動作即可,整個邏輯也是非常清晰的。

插件 Vue 化引發的一些思考

這篇文章我不僅僅是要教會大家封裝一個 scroll 組件,還想傳遞一些把第三方插件(原生 JS 實現)Vue 化的思考過程。很多學習 Vue.js 的同學可能還停留在 “XX 效果如何用 Vue.js 實現” 的程度,其實把插件 Vue 化有兩點很關鍵,一個是對插件本身的實現原理很了解,另一個是對 Vue.js 的特性很了解。對插件本身的實現原理了解需要的是一個思考和鑽研的過程,這個過程可能困難,但是收獲也是巨大的;而對 Vue.js 的特性的了解,是需要大家對 Vue.js 多多使用,學會從平時的項目中積累和總結,也要善於查閱 Vue.js 的官方文檔,關注一些 Vue.js 的升級等。

所以,我們拒絕伸手黨,但也不是鼓勵大家什么時候都要去造輪子,當我們在使用一些現成插件的同時,也希望大家能多多思考,去探索一下現象背后的本質,把 “XX 效果如何用 Vue.js 實現” 這句話從問號變成句號。


以下內容是我在作者基礎上添加了一些交互效果,和作者的放在一起做成一個組件,可以直接拿去用。為了更容易看懂我的思路,進行了簡要的注釋。

<template>
  <div ref="wrapper" class="better-scroll-root">  <!--該節點需要定位,內容以此節點的盒模型為基礎滾動。另外,該節點的背景色配合上拉加載、下拉刷新的UI,正常情況下不可作它用。-->
    <div class="content-bg better-scroll-container">  <!--如果需要調滾動內容的背景色,則改該節點的背景色-->
        <div> <!--不太需要,待優化-->
            <div v-if="pulldown" class="pulldown-tip">
                <i class="pull-icon indexicon icon-pull-down" :class="[pulldownTip.rotate]"></i>
                <span class="tip-content">{{pulldownTip.text}}</span>
            </div>
            <div v-show="loadingStatus.showIcon || loadingStatus.status" class="loading-pos">
                <div v-show="loadingStatus.showIcon" class="loading-container">
                    <div class="cube">
                        <div class="side side1"></div>
                        <div class="side side2"></div>
                        <div class="side side3"></div>
                        <div class="side side4"></div>
                        <div class="side side5"></div>
                        <div class="side side6"></div>
                    </div>
                </div>
                <span class="loading-connecting">{{loadingStatus.status}}</span>
            </div>
        </div>
        <slot></slot>
    </div>
</div>
</template>
<script>
import BScroll from 'better-scroll'

export default {
    props: {
        /**
         * 1 滾動的時候會派發scroll事件,會截流。
         * 2 滾動的時候實時派發scroll事件,不會截流。
         * 3 除了實時派發scroll事件,在swipe的情況下仍然能實時派發scroll事件
         */
        probeType: {
            type: Number,
            default: 1
        },
        /**
         * 點擊列表是否派發click事件
         */
        click: {
            type: Boolean,
            default: true
        },
        /**
         * 是否開啟橫向滾動
         */
        scrollX: {
            type: Boolean,
            default: false
        },
        /**
         * 是否派發滾動事件
         */
        listenScroll: {
            type: Boolean,
            default: false
        },
        /**
         * 列表的數據
         */
        data: {
            type: Array,
            default: null
        },
        /**
         * 是否派發滾動到底部的事件,用於上拉加載
         */
        pullup: {
            type: Boolean,
            default: false
        },
        /**
         * 是否派發頂部下拉的事件,用於下拉刷新
         */
        pulldown: {
            type: Boolean,
            default: false
        },
        /**
         * 是否派發列表滾動開始的事件
         */
        beforeScroll: {
            type: Boolean,
            default: false
        },
        /**
         * 當數據更新后,刷新scroll的延時。
         */
        refreshDelay: {
            type: Number,
            default: 20
        },
        /**
         * 如果啟用loading交互,傳遞loading的狀態
         * isShow: false
         * showIcon: false,    // 是否顯示loading的icon
         * status: ''  // '正在加載...', '刷新成功', '刷新失敗', ''
         */
        loadingStatus: {
            type: Object,
            default: function () {
                return {
                    showIcon: false,
                    status: ''
                };
            }
        },
        /**
         * 是否啟用下拉刷新的交互
         */
        pulldownUI: {
            type: Boolean,
            default: false
        },
        /**
         * 是否啟用上拉加載的交互
         */
        pullupUI: {
            type: Boolean,
            default: false
        }
    },
    data() {
        return {
            loadingConnecting: false,
            pulldownTip: {
                text: '下拉刷新',     // 松開立即刷新
                rotate: ''    // icon-rotate
            },

        };
    },
    mounted() {
        // 保證在DOM渲染完畢后初始化better-scroll
        setTimeout(() => {
            this._initScroll()
        }, 20)
    },
    methods: {
        _initScroll() {
            if (!this.$refs.wrapper) {
                return;
            }
            // better-scroll的初始化
            this.scroll = new BScroll(this.$refs.wrapper, {
                probeType: this.probeType,
                click: this.click,
                scrollX: this.scrollX
            });

            // 是否派發滾動事件
            if (this.listenScroll || this.pulldown || this.pullup) {
                let me = this;
                this.scroll.on('scroll', (pos) => {
                    if (this.listenScroll) {
                        me.$emit('scroll', pos);
                    }

                    if (this.pulldown) {
                        // 下拉動作
                        if (pos.y > 50) {
                            this.pulldownTip = {
                                text: '松開立即刷新',
                                rotate: 'icon-rotate'
                            }
                        } else {
                            this.pulldownTip = {
                                text: '下拉刷新',     // 松開立即刷新
                                rotate: ''    // icon-rotate
                            }
                        }
                    }

                    if (this.pullup) {

                    }
                })
            }

            // 是否派發滾動到底部事件,用於上拉加載
            if (this.pullup) {
                this.scroll.on('scrollEnd', () => {
                    console.log('scrollEnd');
                    console.log(this.scroll);
                    // 滾動到底部
                    if (this.scroll.y <= (this.scroll.maxScrollY + 50)) {
                        this.$emit('scrollToEnd');
                    }
                });
            }

            // 是否派發頂部下拉事件,用於下拉刷新
            if (this.pulldown) {
                this.scroll.on('touchend', (pos) => {
                    // 下拉動作
                    if (pos.y > 50) {
                        setTimeout(() => {
                            // 重置提示信息
                            this.pulldownTip = {
                                text: '下拉刷新',     // 松開立即刷新
                                rotate: ''    // icon-rotate
                            }
                        },600);
                        this.$emit('pulldown');
                    }
                });
            }

            // 是否派發列表滾動開始的事件
            if (this.beforeScroll) {
                this.scroll.on('beforeScrollStart', () => {
                    this.$emit('beforeScroll')
                });
            }
        },
        disable() {
            // 代理better-scroll的disable方法
            this.scroll && this.scroll.disable();
        },
        enable() {
            // 代理better-scroll的enable方法
            this.scroll && this.scroll.enable();
        },
        refresh() {
            // 代理better-scroll的refresh方法
            this.scroll && this.scroll.refresh();
        },
        scrollTo() {
            // 代理better-scroll的scrollTo方法
            this.scroll && this.scroll.scrollTo.apply(this.scroll, arguments);
        },
        scrollToElement() {
            // 代理better-scroll的scrollToElement方法
            this.scroll && this.scroll.scrollToElement.apply(this.scroll, arguments);
        }
    },
    watch: {
        // 監聽數據的變化,延時refreshDelay時間后調用refresh方法重新計算,保證滾動效果正常
        data() {
            setTimeout(() => {
                this.refresh();
            }, this.refreshDelay);
        }
    }
}
</script>
<style lang="scss" rel="stylesheet/scss">
$cube-size: 10px; // 項目中用了scss,沒用的話,替換掉樣式中的變量即可
.better-scroll-root {
    background-color: rgba(7, 17, 27, 0.7);
    .loading-pos, .pulldown-tip {
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height: 35px;
        color: #fcfcfc;
        text-align: center;
        z-index: 2000;
    }
    .loading-pos {
        background-color: rgba(7, 17, 27, 0.7);
    }
    .pulldown-tip {
        top: -50px;
        height: 50px;
        line-height: 50px;
        z-index: 1;
    }
    .pull-icon {
        position: absolute;
        top: 0;
        left: 30%;
        color: #a5a1a1;
        font-size: 1.5em;
        transition: all 0.15s ease-in-out;
    }
    .pull-icon.icon-rotate {
        transform:rotate(180deg);
    }
    
    .loading-container {
        position: absolute;
        height: $cube-size;
        width: $cube-size;
        left: 35%;
        top: 50%;
        transform: translate(-50%, -50%);
        perspective: 40px;
    }
    .loading-connecting {
        line-height: 35px;
    }
    .cube{
        height:$cube-size;
        width:$cube-size;
        transform-origin:50% 50%;
        transform-style:preserve-3d;
        animation:rotate 3s infinite ease-in-out;
    }
    .side{
        position:absolute;
        height:$cube-size;
        width:$cube-size;
        border-radius:50%;
    }
    .side1{
        background: #4bc393;
        transform:translateZ($cube-size);
    }
    .side2{
        background:#FF884D;
        transform:rotateY(90deg) translateZ($cube-size);
    }
    .side3{
        background:#32526E;
        transform:rotateY(180deg) translateZ($cube-size);
    }
    .side4{
        background: #c53fa3;
        transform:rotateY(-90deg) translateZ($cube-size);
    }
    .side5{
        background:#FFCC5C;
        transform:rotateX(90deg) translateZ($cube-size);
    }
    .side6{
        background:#FF6B57;
        transform:rotateX(-90deg) translateZ($cube-size);
    }
    
    @keyframes rotate{
        0%{
            transform:rotateX(0deg) rotateY(0deg);
        }
        50%{
            transform:rotateX(360deg) rotateY(0deg);
        }
        100%{
            transform:rotateX(360deg) rotateY(360deg);
        }
    }
}
</style>

下拉刷新,上拉加載(暫時未做),刷新中等效果如下:

 

  

以上內容還不夠精細,等這段時間忙過去了會繼續優化。如有bug,歡迎各位看官批評指正。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM