本文转载自:小程序粘性布局组件实现

# 一、前言

开发中,我们经常会遇需要让组件在屏幕范围内时,按照正常布局排列,而组件滚出屏幕范围时,让其始终固定在屏幕顶部的情况,也就是常说的粘性布局。今天我们就一起用小程序来实现一个适用于不同场景下的粘性布局组件。

# 二、demo演示

如图,实现的组件主要适用于以下几种场景:

  1. 吸顶页面最上方;
  2. 吸顶与页面有固定距离的位置;
  3. 在指定容器内吸顶;
  4. 嵌套在scroll-view中吸顶。 0

# 三、代码演示

其中,粘性组件通过调用,参数信息用法如下:

滚动时触发scroll函数,其中isFixed为是否吸顶,scrollTop为距离顶部的位置。详细代码如下。

# 3.1 页面代码

# 3.1.1 基础用法

<view class="weimob-block">
  <view class="weimob-title">基础用法</view>
  <view class="weimob-body">
    <weimob-sticky>
    <!-- 需要粘性的部分 -->
      <button class="margin-left-base" size="mini">
        基础用法
      </button>
    </weimob-sticky>
  </view>
</view>

# 3.1.2 吸顶距离

<view class="weimob-block">
  <view class="weimob-title">吸顶距离</view>
  <view class="weimob-body">
  <!-- 吸顶时与顶部的距离,单位px -->
    <weimob-sticky offset-top="{{ 50 }}">
    <!-- 需要粘性的部分 -->
      <button class="margin-left-top" type="primary" size="mini">
        吸顶距离
      </button>
    </weimob-sticky>
  </view>
</view>

# 3.1.3 指定容器

<view class="weimob-block">
  <view class="weimob-title">指定容器</view>
  <view class="weimob-body">
  <!-- 这里需要固定高度 -->
    <view id="container" style="height: 300rpx;background-color: #fff">
      <weimob-sticky container="{{ container }}">
        <button size="mini" class="margin-left-special">
          指定容器
        </button>
      </weimob-sticky>
    </view>
  </view>
</view>

# 3.1.4 嵌套在scroll-view使用

<view class="weimob-block">
  <view class="weimob-title">嵌套在 scroll-view 内使用</view>
  <!-- 这里需要固定高度,scroll-view里的元素高度需要大于其高度 -->
  <scroll-view
		bind:scroll="onScroll"
		scroll-y
		id="scroller"
		style="height: 400rpx; background-color: #fff;margin-top: 40rpx;"
	>
		<view style="height: 800rpx">
			<weimob-sticky
				scroll-top="{{ scrollTop }}"
				offset-top="{{ offsetTop }}"
			>
				<button size="mini" class="margin-left-scoll">
					嵌套在 scroll-view 内
				</button>
			</weimob-sticky>
		</view>
	</scroll-view>
</view>

# 页面js

Page({
  data: {
    container: null, //一个函数,返回容器对应的 NodesRef 节点
    scrollTop: 60, // 当前滚动区域的滚动位置,非null时会禁用页面滚动事件的监听
    offsetTop: 0  // 吸顶时与顶部的距离,单位px
  },
  onReady() {
  // 页面渲染完,获取节点信息
    this.setData({
      container: () => wx.createSelectorQuery().select('#container'),
    });
  },
  onScroll(event) {
   // 容器滚动时获取节点信息
    wx.createSelectorQuery()
      .select('#scroller')
      .boundingClientRect((res) => {
        this.setData({
          scrollTop: event.detail.scrollTop,
          offsetTop: res.top,
        });
      })
      .exec();
  }
});

# 3.2 组件代码

# 组件wxml

<wxs src="./index.wxs" module="computed" />
<view
  class="weimob-sticky"
  style="{{ computed.containerStyle({ fixed, height, zIndex }) }}"
>
  <view
    class="{{ fixed ? 'weimob-sticky-wrap--fixed' : ''}}"
    style="{{ computed.wrapStyle({ fixed, offsetTop, transform, zIndex }) }}"
  >
    <slot />
  </view>
</view>

# 组件wxs

这里使用使用小程序的wxs对吸顶元素的transform,top,height,z-index元素进行实时渲染,ios设备在滚动监听时性能会优于在js 2-20倍,androd设备效率暂无差异。

function wrapStyle(data) {
  var style = "";
	if (data.transform) {
		style += 'transform: translate3d(0, ' + data.transform + 'px, 0);'
	}
	if (data.fixed) {
    style += 'top: ' + data.offsetTop + 'px;'
	}
	if (data.zIndex) {
		style += 'z-index: ' + data.zIndex + ';'
	}
	return style;
}
function containerStyle(data) {
  var style = "";
	if (data.fixed) {
    style += 'height: ' + data.height + 'px;'
	}
	if (data.zIndex) {
		style += 'z-index: ' + data.zIndex + ';'
	}
	return style;
}
module.exports = {
  wrapStyle: wrapStyle,
  containerStyle: containerStyle
}

# 组件js

import pageScrollMixin from "./page-scroll";
const ROOT_ELEMENT = ".weimob-sticky";
Component({
  options: {
		multipleSlots: true
	},
  properties: {
    zIndex: {
      type: Number,
      value: 99
    },
    offsetTop: {
      type: Number,
      value: 0,
      observer: "onScroll"
    },
    disabled: {
      type: Boolean,
      observer: "onScroll"
    },
    container: {
      type: null,
      observer: "onScroll"
    },
    scrollTop: {
      type: null,
      observer(val) {
        this.onScroll({
          scrollTop: val
        });
      }
    }
  },
  data: {
    height: 0,
    fixed: false,
    transform: 0
  },
  behaviors: [pageScrollMixin(function pageScrollMixinCallback(event) {
    // 非null时会禁用页面滚动事件的监听
    if (this.data.scrollTop != null) {
      return;
    }
    this.onScroll(event);
  })],
  lifetimes: {
    attached() {
      this.onScroll();
    }
  },
  methods: {
    onScroll({
      scrollTop
    } = {}) {
      const {
        container,
        offsetTop,
        disabled
      } = this.data;
      if (disabled) {
        this.setDataAfterDiff({
          fixed: false,
          transform: 0
        });
        return;
      }
      this.scrollTop = scrollTop || this.scrollTop;
      if (typeof container === "function") {
        // 情况一:指定容器下时,吸顶距离+吸顶元素高度>容器高度+容器距顶部距离,随页面滚动;
        // 情况二:指定容器下时,吸顶距离>吸顶元素高度,元素固定;
        // 情况三:元素初始化。
        // this.getRect获取节点ROOT_ELEMENT相对于显示区域的top,height等信息,通过root获取
        // this.getContainerRect获取父容器相对于显示区域的top,height等信息,通过container获取
        Promise.all([this.getRect(ROOT_ELEMENT), this.getContainerRect()]).then( 
        ([root, container]) => {
          if (offsetTop + root.height > container.height + container.top) {
            this.setDataAfterDiff({
              fixed: false,
              transform: container.height - root.height
            });
          } else if (offsetTop >= root.top) {
            this.setDataAfterDiff({
              fixed: true,
              height: root.height,
              transform: 0
            });
          } else {
            this.setDataAfterDiff({
              fixed: false,
              transform: 0
            });
          }
        });
        return;
      }else{
        this.getRect(ROOT_ELEMENT).then(root => {
          // 吸顶时与顶部的距离小于可视区域的top距离时,随着滚动条滚动,否则吸顶
          if (offsetTop >= root.top) {
            this.setDataAfterDiff({
              fixed: true,
              height: root.height
            });
            this.transform = 0;
          } else {
            this.setDataAfterDiff({
              fixed: false
            });
          }
          return Promise.resolve();
        });
      }
    },
    setDataAfterDiff(data) {
      // 比较数据是否与上次相同,不同则触发父组件scroll事件更新isFixed,scrollTop。
      wx.nextTick(() => {
        const diff = Object.keys(data).reduce((prev, key) => {
          const prevCopy = prev;
          if (data[key] !== this.data[key]) {
            prevCopy[key] = data[key];
          }
          return prevCopy;
        }, {});
        this.setData(diff);
        this.triggerEvent("scroll", {
          scrollTop: this.scrollTop,
          isFixed: data.fixed || this.data.fixed
        });
      });
    },
    getContainerRect() {
      const nodesRef = this.data.container();
      return new Promise(resolve => nodesRef.boundingClientRect(resolve).exec());
    },
    getRect(selector) {
      return new Promise(resolve => {
        wx.createSelectorQuery().in(this).select(selector).boundingClientRect(rect => {
          resolve(rect);
        }).exec();
      });
    }
  }
});

# page-scroll.js

滚动事件在页面进入和离开时共享的pageScrollMixin函数。

function getCurrentPage() {
  const pages = getCurrentPages();
  return pages[pages.length - 1] || {};
}
function onPageScroll(event) {
  const {
    weimobPageScroller = []
  } = getCurrentPage();
  weimobPageScroller.forEach(scroller => {
    if (typeof scroller === "function" && event) {
      // @ts-ignore
      scroller(event);
    }
  });
}
const pageScrollMixin = scroller => Behavior({
  attached() {
    const page = getCurrentPage();
    if (Array.isArray(page.weimobPageScroller)) {
      page.weimobPageScroller.push(scroller.bind(this));
    } else {
      page.weimobPageScroller = typeof page.onPageScroll === "function" ? [page.onPageScroll.bind(page), scroller.bind(this)] : [scroller.bind(this)];
    }
    page.onPageScroll = onPageScroll;
  },
  detached() {
    const page = getCurrentPage();
    page.weimobPageScroller = (page.weimobPageScroller || []).filter(item => item !== scroller);
  }
});
export default pageScrollMixin;

# 总结

最后,我将上述代码放在了代码片段中供大家使用了解,https://developers.weixin.qq.com/s/qiym3wmr7znx,希望能够帮到小伙伴们,欢迎评论区建议或指教哦~