微信小程序 可滚动柱状图

482 阅读2分钟
效果图

QQ录屏20220217161513 (1).gif

WXML
<view class="slide-container" style="height: {{chartHeight}}rpx">
  <!-- y 轴坐标 -->
  <view class="slide-y-axis" wx:if="{{showYAxis}}" style="height:{{YAxisHeight}}rpx">
    <view wx:for="{{YAxis}}" wx:key="index">{{item}}</view>
  </view>
  <!-- 数据滚动区域 -->
  <scroll-view
    scroll-x
    enhanced="{{true}}"
    show-scrollbar="{{false}}"
    scroll-with-animation
    bindscroll="scroll"
    style="overflow: scroll;"
  >
    <view class="slide-charts" style="height: {{chartHeight}}rpx;">
      <view class="slide-chart-item" wx:for="{{chartData}}" wx:key="index">
        <!-- 条形图 -->
        <view
          class="slide-bar {{index==currentIndex?'active': ''}}"
          style="height:{{item.height}}rpx"
        >
          <!-- 数据提示框 -->
          <view class="tip" style="left: {{item.left}}px" hidden="{{index!==currentIndex}}">
            <view class="tip-content">{{item.date}} {{item.hour}}:00 {{item.value}}</view>
            <view
              class="tip-triangle"
              style="left: {{-item.left}}px;border-width: {{barVisWidth / 2}}px"
            ></view>
          </view>
        </view>

        <!-- x 轴坐标 -->
        <!-- 小时非 4 的倍数、第一个和最后一个不显示 -->
        <view
          class="slide-x-axis"
          hidden="{{item.hour % 4 !== 0 || index === 0 || index === chartData.length - 1}}"
          >{{item.hour === 0?item.date:item.hour}}</view
        >
      </view>
    </view>
  </scroll-view>
</view>
WXSS
.slide-container {
  display: flex;
  align-items: flex-end;
  font-family: 'Consolas';
}

/* y 轴 */
.slide-y-axis {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-end;
  margin-right: 0.5em;
  margin-bottom: 1em;
}

/* 条形图横向布局 */
.slide-charts {
  display: flex;
  align-items: stretch;
}

/* 每一个数据项 */
.slide-chart-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;

  /* 这里设置柱状图每一项的宽度 */
  /* 不能直接使用 width */
  flex: 0 0 0.5em;
}

.slide-chart-item:not(:last-of-type) {
  padding-right: 0.2em;
}

.slide-bar {
  background-color: #3cd500;
  transition: all 0.2s linear;
  position: relative;
  width: 100%;
  margin-top: 0.5em;

  /* 这个 margin 把条形图抬起来, 放 x 轴 */
  margin-bottom: 1em;
}

.slide-bar.active {
  background-color: #01a84c;
}

.slide-x-axis {
  /* x 轴绝对定位 */
  position: absolute;
}

/* 提示框 */
.tip {
  background-color: #f55555;
  color: #fff;
  white-space: nowrap;
  position: absolute;
  top: -60rpx;
  width: 240rpx;
  z-index: 2;
}

.tip>.tip-content {
  text-align: center;
}

.tip > .tip-triangle {
  width: 0;
  height: 0;
  border-left: solid transparent;
  border-right: solid transparent;
  border-top: solid #f55555;
  position: absolute;
  top: 100%;
}
JS
/**
 * 将一个数扩大成最接近于它的整 5 倍
 * 这个是为了获取 y 轴最大值
 * @param {Number} n
 */
function ceilToTen(n) {
  return Math.ceil(n / 5) * 5;
}

Component({
  properties: {
    chartData: Array, // 接收的数据
    chartHeight: {
      type: Number, // 条形图高度默认 300rpx
      value: 300,
    },
    showYAxis: {
      // 是否显示 y 轴
      type: Boolean,
      value: true,
    },
  },

  data: {
    YAxis: [], // Y 轴数据
    YAxisHeight: 0, // Y 轴高度
    currentIndex: 0, // 当前位置
    scrollWidth: 0, // scroll-view 的实际宽度
    scrollEnd: 0, // scroll-view 拉到底时滑动距离
    barWidth: 0, // bar 的总宽度(包括 padding)
    barVisWidth: 0, // bar 的可见宽度
  },

  lifetimes: {
    ready() {
      // 顶部留 60 rpx 的空白给数据提示框
      const height = this.data.chartHeight - 60;
      const data = this.data.chartData;

      const value = data.map(e => e.value); // 用于后面计算最大值

      const Max = ceilToTen(Math.max(...value));

      data.forEach(e => {
        e.height = height * (e.value / Max);
      });

      this.setData({
        chartData: data,
        YAxis: [Max, Max / 2, 0],
        YAxisHeight: height,
      });

      // 获取 scroll-view 的宽度和单个 bar 的总宽度(包括 padding)
      this.createSelectorQuery()
        .in(this)
        .selectAll('.slide-chart-item')
        .boundingClientRect(rect => {
          this.setData({
            scrollWidth: rect.length * rect[0].width,
            barWidth: rect[0].width,
          });
        })
        .exec();

      // 获取单个 bar 的宽度(不包括 padding)
      this.createSelectorQuery()
        .in(this)
        .select('.slide-bar')
        .boundingClientRect(rect => {
          this.setData({
            barVisWidth: rect.width,
          });
        })
        .exec();

      // 获取 scroll-view 的拉到底时滑动距离
      this.createSelectorQuery()
        .in(this)
        .select('.slide-charts')
        .boundingClientRect(rect => {
          this.setData({
            scrollEnd: this.data.scrollWidth - rect.width,
          });
        })
        .exec();

      // 设置提示框绝对定位的 left
      this.createSelectorQuery()
        .in(this)
        .select('.tip')
        .boundingClientRect(rect => {
          // 这里减去 barVisWidth 是为了让最后一个提示框右边对齐 bar 的右边
          const width = rect.width - this.data.barVisWidth;
          // 这是数据提示框的宽度
          const tipWidth = rect.width - 2 * this.data.barVisWidth;
          const data = this.data.chartData;
          const count = data.length - 1;

          data.forEach((e, i) => {
            // 按一定比例换算每个 tip 绝对定位的 left
            e.left = -width * (i / count);
            // Tip 底部小三角绝对定位的 left, 这个方向与 tip 相反
            e.tipLeft = tipWidth * (i / count);
          });

          this.setData({
            chartData: data,
          });
        })
        .exec();
    },
  },

  methods: {
    // 正在滑动
    scroll(e) {
      // 防止滑到底了还可以继续滑...
      if (e.detail.scrollLeft <= this.data.scrollEnd) {
        const percent = e.detail.scrollLeft / this.data.scrollEnd;
        const loc = this.data.scrollWidth * percent;
        const currentIndex = Math.floor(loc / this.data.barWidth);
        this.setData({ currentIndex });
      }
    },
  },
});