scale-ruler.vue 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. <template>
  2. <view class="ruler-wrapper" v-show="visible">
  3. <scroll-view class="ruler-scroll" scroll-x :scroll-left="scrollLeft" :scroll-with-animation="useAnimation"
  4. @scroll="onScroll" @touchend="onTouchEnd" @touchcancel="onTouchEnd" scroll-into-view="">
  5. <view class="ruler-inner" :style="{ width: totalWidth + 'px' }">
  6. <view v-for="(g, idx) in gridList" :key="idx" class="grid-item" :class="{ long: g.isLongGrid }"
  7. :style="{ width: props.gutter + 'px' }">
  8. <view class="tick" :class="{ long: g.isLongGrid }"></view>
  9. <text v-if="g.showText" class="grid-num">{{ g.displayNum }}</text>
  10. </view>
  11. </view>
  12. </scroll-view>
  13. <view class="indicator">
  14. <view class="value-box"><text class="value-text">{{ currentValue }}</text></view>
  15. <view class="indicator-triangle"></view>
  16. <view class="indicator-line"></view>
  17. </view>
  18. </view>
  19. </template>
  20. <script setup lang="ts">
  21. import { ref, computed, watch, onMounted } from 'vue'
  22. import { getCurrentInstance } from 'vue'
  23. interface GridItem { num: number; displayNum: number | string; isLongGrid: boolean; showText: boolean }
  24. const props = defineProps({
  25. min: { type: Number, default: 0 },
  26. max: { type: Number, default: 100 },
  27. step: { type: Number, default: 1 },
  28. unit: { type: String, default: '' },
  29. initialValue: { type: Number, default: 0 },
  30. gutter: { type: Number, default: 10 },
  31. visible: { type: Boolean, default: true },
  32. useAnimation: { type: Boolean, default: true }
  33. })
  34. const emit = defineEmits(['update:value', 'change'])
  35. const instance = getCurrentInstance()
  36. const scrollLeft = ref(0)
  37. const actualScrollLeft = ref(0)
  38. const currentValue = ref(Math.round(props.initialValue || props.min))
  39. // Support decimal step: convert to integer space using scale factor
  40. function decimalPlaces(n: number) {
  41. const s = String(n)
  42. if (s.indexOf('e-') >= 0) {
  43. const m = s.match(/e-(\d+)$/)
  44. return m ? parseInt(m[1], 10) : 0
  45. }
  46. const idx = s.indexOf('.')
  47. return idx >= 0 ? s.length - idx - 1 : 0
  48. }
  49. const stepDecimals = decimalPlaces(props.step)
  50. const scaleFactor = Math.pow(10, stepDecimals)
  51. const intMin = Math.round(props.min * scaleFactor)
  52. const intMax = Math.round(props.max * scaleFactor)
  53. const intStep = Math.round(props.step * scaleFactor)
  54. const totalUnits = computed(() => Math.round((intMax - intMin) / intStep))
  55. const extraGridCount = Math.ceil((uni?.getSystemInfoSync?.().windowWidth || 375) / props.gutter / 2) * 2
  56. const gridList = ref<GridItem[]>([])
  57. const totalWidth = ref(0)
  58. const gridItemStyle = computed(() => ({ width: props.gutter + 'px', height: '24px' }))
  59. function buildGrid() {
  60. const count = totalUnits.value
  61. const arr: GridItem[] = []
  62. for (let i = 0; i <= count + extraGridCount * 1; i++) {
  63. const numIndex = i - extraGridCount / 2
  64. const intNum = intMin + numIndex * intStep
  65. const num = intNum / scaleFactor
  66. const displayNum = (Number.isInteger(num) ? num : Number(num.toFixed(stepDecimals)))
  67. // mark long grid every 10 steps in integer space (10 * intStep)
  68. const isLongGrid = ((intNum - intMin) % (intStep * 10) === 0)
  69. const showText = isLongGrid && num >= props.min && num <= props.max
  70. arr.push({ num, displayNum, isLongGrid, showText })
  71. }
  72. gridList.value = arr
  73. totalWidth.value = arr.length * props.gutter
  74. }
  75. buildGrid()
  76. const offsetScroll = computed(() => extraGridCount * props.gutter/2)
  77. const windowWidth = uni?.getSystemInfoSync?.().windowWidth || 375
  78. const halfWindow = windowWidth / 2
  79. onMounted(() => {
  80. // initialize using integer index space
  81. const initIndex = Math.round((Math.round((props.initialValue || props.min) * scaleFactor) - intMin) / intStep)
  82. scrollLeft.value = offsetScroll.value + initIndex * props.gutter + props.gutter / 2 - halfWindow
  83. const initVal = (intMin + initIndex * intStep) / scaleFactor
  84. currentValue.value = Number(stepDecimals ? initVal.toFixed(stepDecimals) : String(initVal))
  85. scrollLeft.value = offsetScroll.value + initIndex * props.gutter + props.gutter / 2 - halfWindow
  86. currentValue.value = Math.round(initIndex * props.step + props.min)
  87. })
  88. function onScroll(e: any) {
  89. const left = e?.detail?.scrollLeft ?? e?.detail?.scrollLeft ?? 0
  90. actualScrollLeft.value = left
  91. const numIndex = Math.round((left + halfWindow - offsetScroll.value - props.gutter / 2) / props.gutter)
  92. let intValue = intMin + numIndex * intStep
  93. if (intValue < intMin) intValue = intMin
  94. if (intValue > intMax) intValue = intMax
  95. const value = intValue / scaleFactor
  96. currentValue.value = Number(stepDecimals ? value.toFixed(stepDecimals) : String(value))
  97. emit('update:value', value)
  98. }
  99. function adjustScrollPosition() {
  100. const left = actualScrollLeft.value
  101. const numIndex = Math.round((left + halfWindow - offsetScroll.value - props.gutter / 2) / props.gutter)
  102. const targetLeft = offsetScroll.value + numIndex * props.gutter + props.gutter / 2 - halfWindow
  103. const maxScroll = Math.max(0, totalWidth.value - windowWidth)
  104. const clamped = Math.min(Math.max(targetLeft, 0), maxScroll)
  105. scrollLeft.value = clamped
  106. }
  107. function onTouchEnd() {
  108. adjustScrollPosition()
  109. const left = scrollLeft.value
  110. const numIndex = Math.round((left + halfWindow - offsetScroll.value - props.gutter / 2) / props.gutter)
  111. let intValue = intMin + numIndex * intStep
  112. if (intValue < intMin) intValue = intMin
  113. if (intValue > intMax) intValue = intMax
  114. const value = intValue / scaleFactor
  115. currentValue.value = Number(stepDecimals ? value.toFixed(stepDecimals) : String(value))
  116. emit('change', value)
  117. }
  118. </script>
  119. <style scoped>
  120. .ruler-wrapper {
  121. position: relative;
  122. height: 420rpx;
  123. display: flex;
  124. align-items: flex-start;
  125. }
  126. .ruler-scroll {
  127. margin-top: 210rpx;
  128. width: 100%;
  129. height: 180rpx;
  130. overflow-x: scroll;
  131. scrollbar-width: none; /* Firefox */
  132. -ms-overflow-style: none; /* IE and Edge */
  133. }
  134. .ruler-scroll::-webkit-scrollbar {
  135. display: none; /* Chrome, Safari, Opera */
  136. }
  137. .ruler-inner {
  138. display: flex;
  139. align-items: flex-start;
  140. }
  141. .grid-item {
  142. width: 10px;
  143. display: flex;
  144. flex-direction: column;
  145. align-items: center;
  146. justify-content: flex-start;
  147. padding-top: 8rpx;
  148. }
  149. .tick {
  150. width: 2px;
  151. margin-top: 10rpx;
  152. height: 60rpx;
  153. background: #d5d5d5;
  154. }
  155. .tick.long {
  156. width: 3px;
  157. margin-top: 0rpx;
  158. /* margin-left: 1px; */
  159. height: 80rpx;
  160. background: #414141;
  161. }
  162. .grid-num {
  163. font-size: 36rpx;
  164. color: #666;
  165. margin-block: 8rpx;
  166. }
  167. .indicator {
  168. position: absolute;
  169. left: 50%;
  170. top: 0;
  171. transform: translateX(-50%);
  172. width: 2px;
  173. height: 100%;
  174. display: flex;
  175. align-items: center;
  176. flex-direction: column;
  177. pointer-events: none;
  178. }
  179. .indicator-line {
  180. width: 4px;
  181. margin-top: -4px;
  182. /* margin-left: 1px; */
  183. height: 15px;
  184. background: #ff6a00;
  185. }
  186. .indicator-triangle {
  187. width: 0;
  188. height: 0;
  189. border-left: 8px solid transparent;
  190. border-right: 8px solid transparent;
  191. border-top: 10px solid #ff6a00;
  192. margin-top: 100px;
  193. }
  194. .value-box {
  195. margin-top: 70rpx;
  196. position: absolute;
  197. left: 50%;
  198. transform: translateX(-50%);
  199. padding: 6rpx 12rpx;
  200. border-radius: 6rpx;
  201. display: flex;
  202. align-items: center;
  203. justify-content: center;
  204. }
  205. .value-text {
  206. color: #000000;
  207. font-size: 70rpx;
  208. font-weight: 900;
  209. line-height: 1;
  210. }
  211. </style>