前言最近在开发一个小程序项目时需要实现一个身高测量功能调研了市面上常见的刻度尺组件后决定自己动手写一个。由于项目同时需要支持纵向身高和横向体重选择两种模式索性做了一个通用组件组件特性- 双向模式支持纵向和横向两种刻度尺- 密度可调通过 density 属性控制刻度线疏密- 高度可配每个刻度的高度/宽度可通过 itemHeight 灵活配置- 主题自定义支持自定义颜色、大小等样式属性- 交互友好滚动停止后自动吸附到刻度线核心实现结尾附完整代码核心原理利用 scroll-view 的 scrollTop/scrollLeft 控制滚动位置通过数学计算实现滚动位置与刻度值的双向转换。一、数据结构type ScaleItem { val: number // 刻度值 isBig: boolean // 是否大刻度显示数字和粗线 } //每个刻度线对应一个刻度值isBig 用于判断是否是大刻度。二、 刻度线生成buildScaleListV() { const { minValue, maxValue, step, density } this.properties const list: ScaleItem[] [] for (let i minValue; i maxValue; i density) { list.push({ val: i, isBig: i % step 0 }) } this.setData({ scaleList: list }) }关键点- density 控制循环步长实现刻度线疏密- isBig 基于刻度值判断不依赖 index三、滚动位置计算// 值 → 滚动位置 calcScrollTop(value: number) { const { minValue, density } this.properties const { itemHeight } this.data // snap 到最近的刻度线值 const validValue Math.round((value - minValue) / density) * density minValue const offset (validValue - minValue) * itemHeight this.setData({ scrollTop: Math.max(0, offset) }) } // 滚动位置 → 值 onScrollV(e: WechatMiniprogram.ScrollViewScroll) { const { minValue, maxValue } this.properties const { itemHeight } this.data const value Math.round(e.detail.scrollTop / itemHeight) minValue // ... }关键点- itemHeight 代表两个刻度值之间的物理距离- 滚动计算始终基于刻度值不考虑 density- calcScrollTop 会自动将值吸附到最近的刻度线四、布局结构!-- 纵向刻度尺 -- view classruler-vertical !-- 固定标记圆球指示线 -- view classscroll-marker-vertical.../view !-- 可滚动区域 -- scroll-view scroll-y bindscrollonScrollV view classspacer-top / view classscale-item-vertical styleheight: {{density * itemHeight}}px; wx:for{{scaleList}} !-- 大刻度/小刻度内容 -- /view view classspacer-bottom / /scroll-view /view关键点- 刻度线高度 density × itemHeight- 首尾 spacer 确保初始位置居中使用示例默认刻度尺!-- 纵向刻度尺身高测量 -- ruler directionvertical width{{120}} height{{450}} item-height{{13}} density{{3}} step{{10}} big-line-color#1890ff marker-color#1890ff min-value{{100}} max-value{{220}} default-value{{170}} bind:changeonRulerChange /完整代码ruler: 小程序刻度尺组件包含示例页面https://gitee.com/signup_xiuxiuxiu/ruler1. ruler.wxml!-- ruler 组件 - 纵向/横向刻度尺 -- !-- 纵向刻度尺 -- view classruler-vertical wx:if{{direction vertical}} stylewidth: {{width}}px; height: {{height}}px; background-color: {{bgColor}}; !-- 滚动标志圆球在左指示线在右 -- view classscroll-marker-vertical styleheight: {{ballSize}}px; top: {{ballTop}}px; view classmarker-ball stylewidth: {{ballSize}}px; height: {{ballSize}}px; background-color: {{markerColor}}; box-shadow: 0 1px 4px {{markerColor}}40; wx:if{{showBall}}/view view classmarker-line stylewidth: {{lineWidth}}px; height: 2px; background-color: {{markerColor}};/view /view !-- 刻度列表 -- scroll-view classruler-scroll-vertical scroll-y enhanced{{true}} show-scrollbar{{false}} bindscrollonScrollV scroll-top{{scrollTop}} view classspacer-top styleheight: {{height / 2}}px;/view view classscale-item-vertical {{item.isBig ? big : small}} styleheight: {{density * itemHeight}}px; wx:for{{scaleList}} wx:keyindex block wx:if{{item.isBig}} text classscale-text-vertical wx:if{{showScaleText}} stylefont-size: {{scaleFontSize}}px; color: {{scaleTextColor}}; {{item.val}}/text view classscale-line-big-vertical styleheight: {{bigLineHeight}}px; width: {{lineWidth}}px; background-color: {{bigLineColor}};/view /block block wx:else view classscale-line-vertical styleheight: {{smallLineHeight}}px; width: {{smallLineWidth}}px; background-color: {{smallLineColor}};/view /block /view view classspacer-bottom styleheight: {{height / 2}}px;/view /scroll-view /view !-- 横向刻度尺 -- view classruler-horizontal wx:if{{direction horizontal}} stylewidth: {{width}}px; height: {{height}}px; background-color: {{bgColor}}; !-- 滚动标志指示线在上圆球在下 -- view classscroll-marker-horizontal stylewidth: {{ballSize}}px;left: {{ballLeft}}px; view classmarker-line-h stylewidth: 2px; height: {{lineWidth}}px; background-color: {{markerColor}};/view view classmarker-ball-h stylewidth: {{ballSize}}px; height: {{ballSize}}px; background-color: {{markerColor}}; box-shadow: 0 1px 4px {{markerColor}}40; wx:if{{showBall}}/view /view !-- 刻度列表 -- scroll-view classruler-scroll-horizontal scroll-x enhanced{{true}} show-scrollbar{{false}} bindscrollonScrollH scroll-left{{scrollLeft}} view classscroll-content-horizontal view classspacer-left stylewidth: {{width / 2}}px;/view view classscale-item-horizontal {{item.isBig ? big : small}} stylewidth: {{density * itemHeight}}px; wx:for{{scaleList}} wx:keyindex block wx:if{{item.isBig}} view classscale-line-big-horizontal stylewidth: {{bigLineHeight}}px; height: {{lineWidth}}px; background-color: {{bigLineColor}};/view text classscale-text-horizontal wx:if{{showScaleText}} stylefont-size: {{scaleFontSize}}px; color: {{scaleTextColor}};{{item.val}}/text /block block wx:else view classscale-line-horizontal stylewidth: {{smallLineHeight}}px; height: {{smallLineWidth}}px; background-color: {{smallLineColor}};/view /block /view view classspacer-right stylewidth: {{width / 2}}px;/view /view /scroll-view /view2. ruler.scss/* 纵向刻度尺 */ .ruler-vertical { position: relative; overflow: hidden; } .scroll-marker-vertical { position: absolute; right: 0; z-index: 10; display: flex; align-items: center; transition: top 0.1s ease-out; } .marker-ball { border-radius: 50%; } .ruler-scroll-vertical { width: 100%; height: 100%; } .scale-item-vertical { display: flex; align-items: flex-start; box-sizing: border-box; .big { justify-content: flex-end; } .small { justify-content: flex-end; } } .scale-text-vertical { transform: translateY(-10px); text-align: right; box-sizing: border-box; padding-right: 15px; } .scale-line-big-vertical { height: 2px; } .scale-line-vertical { height: 1px; } /* 横向刻度尺 */ .ruler-horizontal { position: relative; overflow: hidden; } .scroll-marker-horizontal { position: absolute; top: 0; z-index: 10; display: flex; flex-direction: column; align-items: center; transition: left 0.1s ease-out; } .marker-ball-h { border-radius: 50%; } .ruler-scroll-horizontal { width: 100%; height: 100%; } .scroll-content-horizontal { display: flex; flex-direction: row; align-items: flex-start; height: 100%; width: max-content; } .spacer-left, .spacer-right { flex-shrink: 0; } .scale-item-horizontal { display: flex; flex-direction: column; flex-shrink: 0; box-sizing: border-box; .big { justify-content: flex-end; } .small { justify-content: flex-start; } } .scale-text-horizontal { text-align: center; box-sizing: border-box; padding-top: 15px; transform: translateX(-10px); }3. ruler.ts/** * ruler 组件 - 纵向/横向刻度尺 * * 实现思路 * 1. 通过 direction 切换纵向/横向 * 2. 纵向scroll-y 滚动scrollTop 控制 * 3. 横向scroll-x 滚动scrollLeft 控制 * 4. 滚动标志圆点指示线在固定位置 */ type ScaleItem { val: number // 刻度值 isBig: boolean // 是否大刻度 } Component({ properties: { // 方向配置 direction: { type: String, value: vertical // vertical | horizontal }, // 尺寸配置 width: { type: Number, value: 90 }, height: { type: Number, value: 312 }, itemHeight: { type: Number, value: 13 }, density: { type: Number, value: 1 }, // 刻度样式配置 step: { type: Number, value: 10 }, showScaleText: { type: Boolean, value: true }, scaleFontSize: { type: Number, value: 14 }, scaleTextColor: { type: String, value: #000 }, bigLineHeight: { type: Number, value: 2 }, smallLineHeight: { type: Number, value: 1 }, smallLineWidth: { type: Number, value: 20 }, lineWidth: { type: Number, value: 40 }, ballSize: { type: Number, value: 10 }, // 颜色配置 bigLineColor: { type: String, value: #FD6321 }, smallLineColor: { type: String, value: rgba(253, 99, 33, 0.3) }, markerColor: { type: String, value: #FD6321 }, bgColor: { type: String, value: rgba(253, 99, 33, 0.06) }, showBall: { type: Boolean, value: true }, // 刻度尺范围配置 minValue: { type: Number, value: 100 }, maxValue: { type: Number, value: 220 }, defaultValue: { type: Number, value: 170 } }, data: { scaleList: [] as ScaleItem[], // 纵向 scrollTop: 0, ballTop: 0, containerHeight: 0, // 横向 scrollLeft: 0, ballLeft: 0, containerWidth: 0, // 当前值 currentValue: 170, itemHeight: 13, debounceTimerV: null as number | null, debounceTimerH: null as number | null, }, lifetimes: { attached() { this.initRuler() } }, observers: { defaultValue: function (val: number) { if (val ! this.data.currentValue) { this.setData({ currentValue: val }) if (this.properties.direction horizontal) { this.calcScrollLeft(val) } else { this.calcScrollTop(val) } } } }, methods: { initRuler() { const { defaultValue, itemHeight, direction } this.properties as any this.setData({ itemHeight }) if (direction horizontal) { this.buildScaleListH() this.updateContainerWidth(() { this.calcScrollLeft(defaultValue) }) } else { this.buildScaleListV() this.updateContainerHeight(() { this.calcScrollTop(defaultValue) }) } }, // 纵向 buildScaleListV() { const { minValue, maxValue, step, density } this.properties as any const list: ScaleItem[] [] for (let i minValue; i maxValue; i density) { list.push({ val: i, isBig: i % step 0 }) } this.setData({ scaleList: list }) }, calcScrollTop(value: number) { const { minValue, density } this.properties as any const { itemHeight } this.data // snap 到最近的刻度线值 const validValue Math.round((value - minValue) / density) * density minValue const offset (validValue - minValue) * itemHeight this.setData({ scrollTop: Math.max(0, offset), currentValue: validValue }) }, updateContainerHeight(callback?: () void) { const { ballSize } this.properties as any const query wx.createSelectorQuery().in(this) query.select(.ruler-vertical).boundingClientRect((rect) { if (rect) { const containerHeight rect.height const ballTop containerHeight / 2 - ballSize / 2 this.setData({ containerHeight, ballTop }) if (callback) callback() } }).exec() }, onScrollV(e: WechatMiniprogram.ScrollViewScroll) { const { minValue, maxValue } this.properties as any const { itemHeight, debounceTimerV } this.data const value Math.round(e.detail.scrollTop / itemHeight) minValue const clampedValue Math.max(minValue, Math.min(maxValue, value)) this.setData({ currentValue: clampedValue }) this.triggerEvent(change, { value: clampedValue }) if (debounceTimerV) { clearTimeout(debounceTimerV) } const timer setTimeout(() { this.calcScrollTop(clampedValue) }, 100) this.setData({ debounceTimerV: timer }) }, // 横向 buildScaleListH() { const { minValue, maxValue, step, density } this.properties as any const list: ScaleItem[] [] for (let i minValue; i maxValue; i density) { list.push({ val: i, isBig: i % step 0 }) } this.setData({ scaleList: list }) }, calcScrollLeft(value: number) { const { minValue, density } this.properties as any const { itemHeight } this.data // snap 到最近的刻度线值 const validValue Math.round((value - minValue) / density) * density minValue const offset (validValue - minValue) * itemHeight this.setData({ scrollLeft: Math.max(0, offset), currentValue: validValue }) }, updateContainerWidth(callback?: () void) { const { ballSize } this.properties as any const query wx.createSelectorQuery().in(this) query.select(.ruler-horizontal).boundingClientRect((rect) { if (rect) { const containerWidth rect.width const ballLeft containerWidth / 2 - ballSize / 2 this.setData({ containerWidth, ballLeft }) if (callback) callback() } }).exec() }, onScrollH(e: WechatMiniprogram.ScrollViewScroll) { const { minValue, maxValue } this.properties as any const { itemHeight, debounceTimerH } this.data const value Math.round(e.detail.scrollLeft / itemHeight) minValue const clampedValue Math.max(minValue, Math.min(maxValue, value)) this.setData({ currentValue: clampedValue }) this.triggerEvent(change, { value: clampedValue }) if (debounceTimerH) { clearTimeout(debounceTimerH) } const timer setTimeout(() { this.calcScrollLeft(clampedValue) }, 100) this.setData({ debounceTimerH: timer }) }, } })4. ruler.json{ component: true, usingComponents: {} }配置介绍仓库内的README.md)## 属性说明 ### 方向配置 | 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| | direction | String | vertical | 刻度尺方向vertical 纵向horizontal 横向 | ### 尺寸配置 | 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| | width | Number | 90 | 刻度尺宽度px | | height | Number | 312 | 刻度尺高度px | | itemHeight | Number | 13 | 两个刻度值之间的物理距离px每条刻度线的实际高度 density × itemHeight | ### 刻度样式 | 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| | density | Number | 1 | 刻度线密度每隔 density 个刻度值显示一条刻度线 | | step | Number | 10 | 大刻度间隔每隔多少显示数字和大刻度线 | | showScaleText | Boolean | true | 是否显示刻度数字 | | scaleFontSize | Number | 14 | 刻度数字字号px | | scaleTextColor | String | #000 | 刻度数字颜色 | | bigLineHeight | Number | 2 | 大刻度线高度px | | smallLineHeight | Number | 1 | 小刻度线高度px | | smallLineWidth | Number | 20 | 小刻度线宽度px | | lineWidth | Number | 40 | 刻度线总宽度px | ### 颜色配置 | 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| | bigLineColor | String | #FD6321 | 大刻度线颜色 | | smallLineColor | String | rgba(253,99,33,0.3) | 小刻度线颜色 | | markerColor | String | #FD6321 | 圆球和指示线颜色 | | bgColor | String | rgba(253,99,33,0.06) | 刻度尺背景色 | ### 功能配置 | 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| | ballSize | Number | 10 | 圆球尺寸px | | showBall | Boolean | true | 是否显示圆球 | | minValue | Number | 100 | 刻度尺最小值 | | maxValue | Number | 220 | 刻度尺最大值 | | defaultValue | Number | 170 | 默认值 | ## 事件 ### change 滚动停止后触发返回当前选中值。 xml ruler bind:changeonRulerChange / js onRulerChange(e) { const { value } e.detail console.log(选中值:, value) } ## 使用示例 ### 纵向刻度尺身高测量 xml ruler directionvertical width{{120}} height{{450}} item-height{{13}} density{{3}} step{{10}} show-scale-text{{true}} scale-font-size{{14}} scale-text-color#000 big-line-height{{2}} small-line-height{{1}} small-line-width{{20}} line-width{{40}} ball-size{{10}} big-line-color#1890ff small-line-colorrgba(24, 144, 255, 0.3) marker-color#1890ff bg-colorrgba(24, 144, 255, 0.06) show-ball{{true}} min-value{{100}} max-value{{220}} default-value{{170}} bind:changeonRulerChange / ## 注意事项 1. **itemHeight**代表两个刻度值之间的物理距离px刻度线的实际高度 density × itemHeight 2. **density**刻度线密度1每个刻度值都有刻度线2每隔一个刻度值有刻度线以此类推 3. **滚动范围**刻度范围从 minValue 到 maxValue首尾有半个容器宽/高度的空白区域 4. **默认值**组件初始化时会自动滚动到 defaultValue 指定的位置会自动对齐到刻度线 5. **响应式**修改 defaultValue 会触发组件重新定位到指定值总结这个组件的实现思路比较简单核心就是利用 scroll-view 的 scrollTop/scrollLeft控制滚动位置通过计算实现值与滚动位置的相互转换。density属性的引入让刻度线的疏密变得灵活可调希望能给有类似需求的同学一些参考因为考虑不同项目色调不同尺子的背景颜色刻度线的颜色都可以配置有需要的可以参考实现哈有更好的优化建议也欢迎各位大佬指出共同进步๑ᴗ๑