From 81d668b9966df86d446b161927a07e4b251a93ed Mon Sep 17 00:00:00 2001 From: obroccolio Date: Tue, 18 Aug 2026 23:04:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20TabBar=20=E7=82=B9=E5=87=BB=E6=BB=91?= =?UTF-8?q?=E5=9D=97=E4=BB=8E=E6=97=A7=E4=BD=8D=E7=BD=AE=E6=BB=91=E5=88=B0?= =?UTF-8?q?=E6=96=B0=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 点击时不再让滑块在 touchstart 直接跳到手指位置 - 拖动超过 12px 阈值后才跟手,位置统一用百分比,避免 px/calc 混用导致跳变 - 松手按命中 tab 切页,同一 tab 不重复跳转 --- dist/custom-tab-bar/index.js | 2 +- src/custom-tab-bar/index.tsx | 55 +++++++++++++++++----------- src/custom-tab-bar/tabBarGeometry.js | 17 +++++---- tools/custom-tab-bar.test.mjs | 32 +++++++++------- 4 files changed, 62 insertions(+), 44 deletions(-) diff --git a/dist/custom-tab-bar/index.js b/dist/custom-tab-bar/index.js index 5086f31..8fb0242 100644 --- a/dist/custom-tab-bar/index.js +++ b/dist/custom-tab-bar/index.js @@ -1 +1 @@ -"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{7914:function(n,e,t){var a=t(7842),c=t(5544),i=t(118),r=t(758),u=t.n(r),o=t(6540),s=t(1457),l=t(9703),f=t(6606),h=t(8199);function g(n,e,t){return Number.isFinite(n)?Math.min(t,Math.max(e,n)):e}function v(n,e){return g(n,0,Math.max(0,e))}function m(n,e,t){if(!Number.isFinite(n)||!Number.isFinite(e)||e<=0||t<=0)return 0;var a=v(n,e)/e;return g(Math.floor(a*t),0,t-1)}function p(n){var e=n/750;return{left:36*e,width:Math.max(0,n-72*e)}}var d=t(4848),b=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}],x=100/b.length;function C(n){var e=b.findIndex(function(e){return n.endsWith(e.pagePath)});return b[e>=0?e:0].pagePath}function S(){var n=u().getCurrentInstance().router,e=n&&n.path?n.path:"pages/index/index";return C(e)}function N(){var n=(0,o.useState)((0,s.eW)((0,s.O4)())),e=(0,c.A)(n,2),t=e[0],a=e[1],r=(0,o.useState)(S),h=(0,c.A)(r,2),g=h[0],N=h[1],P=(0,o.useState)(null),q=(0,c.A)(P,2),w=q[0],j=q[1],A=(0,o.useRef)(null),T=(0,o.useRef)(null);(0,o.useEffect)(function(){var n=function(n){return a(n)};return u().eventCenter.on(l.Wo,n),function(){u().eventCenter.off(l.Wo,n)}},[]),(0,o.useEffect)(function(){var n=function(n){!n||"dark"!==n.theme&&"light"!==n.theme||((0,s.Rq)(n.theme),"auto"===(0,s.O4)()&&a(n.theme))},e=u();return e.onThemeChange&&e.onThemeChange(n),function(){e.offThemeChange&&e.offThemeChange(n)}},[]),(0,o.useEffect)(function(){var n=function(n){var e=u().getCurrentInstance().router,t=e&&e.path?e.path:"pages/index/index";N(C(n||t))};return u().eventCenter.on("tabBarChange",n),function(){u().eventCenter.off("tabBarChange",n)}},[]),(0,o.useEffect)(function(){(0,f.G)(t)},[t]);var M=function(n){N(C(n)),u().switchTab({url:n}),u().eventCenter.trigger("tabBarChange",n)},E=function(n){var e=n.touches&&n.touches[0];if(e){var t=p(u().getSystemInfoSync().windowWidth);T.current=t;var a=v(e.clientX-t.left,t.width);A.current=a,j(a)}},I=function(n){if(null!==A.current){var e=T.current,t=n.touches&&n.touches[0];if(e&&t){var a=v(t.clientX-e.left,e.width);A.current=a,j(a)}}},W=function(){var n=T.current,e=A.current;if(n&&null!==e){var t=m(e,n.width,b.length),a=b[t];a&&a.pagePath!==g&&M(a.pagePath)}T.current=null,A.current=null,j(null)},F=function(){T.current=null,A.current=null,j(null)},O=Math.max(0,b.findIndex(function(n){return n.pagePath===g}));return(0,d.jsxs)(i.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,d.jsx)(i.Ss,{className:"tab-bar-gradient"}),(0,d.jsxs)(i.Ss,{className:"custom-tab-bar theme-".concat(t),onTouchStart:E,onTouchMove:I,onTouchEnd:W,onTouchCancel:F,catchMove:!0,children:[(0,d.jsx)(i.Ss,{className:"tab-bar-caustic"}),(0,d.jsx)(i.Ss,{className:"tab-bar-rim"}),(0,d.jsx)(i.Ss,{className:"tab-indicator ".concat(null!==w?"dragging":""),style:{left:null===w?"calc(".concat((O+.5)*x,"%)"):"".concat(w,"px")}}),b.map(function(n){var e=n.pagePath===g;return(0,d.jsxs)(i.Ss,{className:"tab-item ".concat(e?"active":""),children:[(0,d.jsx)(i._V,{className:"tab-icon-img ".concat(e?"active":""),src:e?n.iconActive:n.icon,mode:"aspectFit"}),(0,d.jsx)(i.EY,{className:"tab-label",children:n.label})]},n.pagePath)})]})]})}Component((0,a.createComponentConfig)(N,"custom-tab-bar/index"))}},function(n){var e=function(e){return n(n.s=e)};n.O(0,[907,96,76],function(){return e(7914)});n.O()}]); \ No newline at end of file +"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{7914:function(n,e,t){var a=t(7842),c=t(5544),r=t(118),u=t(758),i=t.n(u),o=t(6540),s=t(1457),l=t(9703),f=t(6606),h=t(8199);function g(n,e,t){return Number.isFinite(n)?Math.min(t,Math.max(e,n)):e}function v(n){return g(n,0,100)}function d(n,e){return!Number.isFinite(n)||e<=0?0:g(Math.floor(v(n)/100*e),0,e-1)}function m(n,e,t){return Math.abs(e-n)>=t}function p(n){var e=n/750;return{left:36*e,width:Math.max(0,n-72*e)}}var b=t(4848),x=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}],C=100/x.length,S=12;function P(n){var e=x.findIndex(function(e){return n.endsWith(e.pagePath)});return x[e>=0?e:0].pagePath}function q(){var n=i().getCurrentInstance().router,e=n&&n.path?n.path:"pages/index/index";return P(e)}function N(){var n=(0,o.useState)((0,s.eW)((0,s.O4)())),e=(0,c.A)(n,2),t=e[0],a=e[1],u=(0,o.useState)(q),h=(0,c.A)(u,2),g=h[0],N=h[1],T=(0,o.useState)(null),w=(0,c.A)(T,2),j=w[0],A=w[1],M=(0,o.useRef)(null),E=(0,o.useRef)(0),I=(0,o.useRef)(null);(0,o.useEffect)(function(){var n=function(n){return a(n)};return i().eventCenter.on(l.Wo,n),function(){i().eventCenter.off(l.Wo,n)}},[]),(0,o.useEffect)(function(){var n=function(n){!n||"dark"!==n.theme&&"light"!==n.theme||((0,s.Rq)(n.theme),"auto"===(0,s.O4)()&&a(n.theme))},e=i();return e.onThemeChange&&e.onThemeChange(n),function(){e.offThemeChange&&e.offThemeChange(n)}},[]),(0,o.useEffect)(function(){var n=function(n){var e=i().getCurrentInstance().router,t=e&&e.path?e.path:"pages/index/index";N(P(n||t))};return i().eventCenter.on("tabBarChange",n),function(){i().eventCenter.off("tabBarChange",n)}},[]),(0,o.useEffect)(function(){(0,f.G)(t)},[t]);var W=function(n){N(P(n)),i().switchTab({url:n}),i().eventCenter.trigger("tabBarChange",n)},O=function(n){var e=n.touches&&n.touches[0];if(e){var t=p(i().getSystemInfoSync().windowWidth);I.current=t,E.current=e.clientX,M.current=null,A(null)}},R=function(n){var e=I.current,t=n.touches&&n.touches[0];if(e&&t&&(null!==M.current||m(E.current,t.clientX,S))){var a=v((t.clientX-e.left)/e.width*100);M.current=a,A(a)}},X=function(n){var e=I.current;if(e){var t;if(null!==M.current)t=d(M.current,x.length);else{var a=n.changedTouches&&n.changedTouches[0],c=a?a.clientX:E.current;t=d(v((c-e.left)/e.width*100),x.length)}var r=x[t];r&&r.pagePath!==g&&W(r.pagePath),I.current=null,E.current=0,M.current=null,A(null)}},k=function(){I.current=null,E.current=0,M.current=null,A(null)},y=Math.max(0,x.findIndex(function(n){return n.pagePath===g}));return(0,b.jsxs)(r.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,b.jsx)(r.Ss,{className:"tab-bar-gradient"}),(0,b.jsxs)(r.Ss,{className:"custom-tab-bar theme-".concat(t),onTouchStart:O,onTouchMove:R,onTouchEnd:X,onTouchCancel:k,catchMove:!0,children:[(0,b.jsx)(r.Ss,{className:"tab-bar-caustic"}),(0,b.jsx)(r.Ss,{className:"tab-bar-rim"}),(0,b.jsx)(r.Ss,{className:"tab-indicator ".concat(null!==j?"dragging":""),style:{left:"".concat(null===j?(y+.5)*C:j,"%")}}),x.map(function(n){var e=n.pagePath===g;return(0,b.jsxs)(r.Ss,{className:"tab-item ".concat(e?"active":""),children:[(0,b.jsx)(r._V,{className:"tab-icon-img ".concat(e?"active":""),src:e?n.iconActive:n.icon,mode:"aspectFit"}),(0,b.jsx)(r.EY,{className:"tab-label",children:n.label})]},n.pagePath)})]})]})}Component((0,a.createComponentConfig)(N,"custom-tab-bar/index"))}},function(n){var e=function(e){return n(n.s=e)};n.O(0,[907,96,76],function(){return e(7914)});n.O()}]); \ No newline at end of file diff --git a/src/custom-tab-bar/index.tsx b/src/custom-tab-bar/index.tsx index 238ba44..43f579d 100644 --- a/src/custom-tab-bar/index.tsx +++ b/src/custom-tab-bar/index.tsx @@ -5,7 +5,7 @@ import { getTheme, resolveTheme, setDetectedSystemTheme } from '../utils/store' import { THEME_CHANGE_EVENT } from '../context/ThemeContext' import { applyPageBackground } from '../utils/themeBackground' import { assetUrl } from '../utils/asset' -import { clampDragX, getTabBarFrame, tabIndexAtX } from './tabBarGeometry' +import { clampPercent, getTabBarFrame, hasDragged, tabIndexAtPercent } from './tabBarGeometry' import './index.scss' const TABS = [ @@ -17,6 +17,7 @@ const TABS = [ ] const TAB_CENTER_STEP = 100 / TABS.length +const DRAG_THRESHOLD_PX = 12 function matchTab(path: string): string { const idx = TABS.findIndex((t) => path.endsWith(t.pagePath)) @@ -39,8 +40,9 @@ export default function CustomTabBar() { // 微信自定义 tabBar 不会自动感知 page 路由变化,用 state 保持选中态响应式 const [activeTab, setActiveTab] = useState(currentTabPath) - const [dragX, setDragX] = useState(null) - const dragXRef = useRef(null) + const [dragPercent, setDragPercent] = useState(null) + const dragPercentRef = useRef(null) + const startXRef = useRef(0) const barFrameRef = useRef<{ left: number; width: number } | null>(null) useEffect(() => { @@ -102,38 +104,47 @@ export default function CustomTabBar() { if (!touch) return const frame = getTabBarFrame(Taro.getSystemInfoSync().windowWidth) barFrameRef.current = frame - const nextX = clampDragX(touch.clientX - frame.left, frame.width) - dragXRef.current = nextX - setDragX(nextX) + startXRef.current = touch.clientX + dragPercentRef.current = null + setDragPercent(null) } const moveDrag = (e: any) => { - if (dragXRef.current === null) return const frame = barFrameRef.current const touch = e.touches && e.touches[0] if (!frame || !touch) return - const nextX = clampDragX(touch.clientX - frame.left, frame.width) - dragXRef.current = nextX - setDragX(nextX) + if (dragPercentRef.current === null && !hasDragged(startXRef.current, touch.clientX, DRAG_THRESHOLD_PX)) { + return + } + const nextPercent = clampPercent(((touch.clientX - frame.left) / frame.width) * 100) + dragPercentRef.current = nextPercent + setDragPercent(nextPercent) } - const endDrag = () => { + const endDrag = (e: any) => { const frame = barFrameRef.current - const x = dragXRef.current - if (frame && x !== null) { - const idx = tabIndexAtX(x, frame.width, TABS.length) - const target = TABS[idx] - if (target && target.pagePath !== activeTab) switchTab(target.pagePath) + if (!frame) return + let targetIndex: number + if (dragPercentRef.current !== null) { + targetIndex = tabIndexAtPercent(dragPercentRef.current, TABS.length) + } else { + const touch = e.changedTouches && e.changedTouches[0] + const x = touch ? touch.clientX : startXRef.current + targetIndex = tabIndexAtPercent(clampPercent(((x - frame.left) / frame.width) * 100), TABS.length) } + const target = TABS[targetIndex] + if (target && target.pagePath !== activeTab) switchTab(target.pagePath) barFrameRef.current = null - dragXRef.current = null - setDragX(null) + startXRef.current = 0 + dragPercentRef.current = null + setDragPercent(null) } const cancelDrag = () => { barFrameRef.current = null - dragXRef.current = null - setDragX(null) + startXRef.current = 0 + dragPercentRef.current = null + setDragPercent(null) } const activeIndex = Math.max(0, TABS.findIndex((t) => t.pagePath === activeTab)) @@ -152,8 +163,8 @@ export default function CustomTabBar() { {TABS.map((tab) => { const isActive = tab.pagePath === activeTab diff --git a/src/custom-tab-bar/tabBarGeometry.js b/src/custom-tab-bar/tabBarGeometry.js index fb35e3a..e81817a 100644 --- a/src/custom-tab-bar/tabBarGeometry.js +++ b/src/custom-tab-bar/tabBarGeometry.js @@ -3,16 +3,17 @@ export function clamp(value, min, max) { return Math.min(max, Math.max(min, value)) } -export function clampDragX(x, barWidth) { - return clamp(x, 0, Math.max(0, barWidth)) +export function clampPercent(value) { + return clamp(value, 0, 100) } -export function tabIndexAtX(x, barWidth, tabCount) { - if (!Number.isFinite(x) || !Number.isFinite(barWidth) || barWidth <= 0 || tabCount <= 0) { - return 0 - } - const ratio = clampDragX(x, barWidth) / barWidth - return clamp(Math.floor(ratio * tabCount), 0, tabCount - 1) +export function tabIndexAtPercent(percent, tabCount) { + if (!Number.isFinite(percent) || tabCount <= 0) return 0 + return clamp(Math.floor((clampPercent(percent) / 100) * tabCount), 0, tabCount - 1) +} + +export function hasDragged(startX, currentX, threshold) { + return Math.abs(currentX - startX) >= threshold } export function getTabBarFrame(windowWidth) { diff --git a/tools/custom-tab-bar.test.mjs b/tools/custom-tab-bar.test.mjs index bdf5c7b..585a489 100644 --- a/tools/custom-tab-bar.test.mjs +++ b/tools/custom-tab-bar.test.mjs @@ -1,21 +1,27 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { clampDragX, getTabBarFrame, tabIndexAtX } from '../src/custom-tab-bar/tabBarGeometry.js' +import { clampPercent, getTabBarFrame, hasDragged, tabIndexAtPercent } from '../src/custom-tab-bar/tabBarGeometry.js' -test('maps a drag x to the tab under the finger', () => { - assert.equal(tabIndexAtX(10, 100, 5), 0) - assert.equal(tabIndexAtX(30, 100, 5), 1) - assert.equal(tabIndexAtX(50, 100, 5), 2) - assert.equal(tabIndexAtX(70, 100, 5), 3) - assert.equal(tabIndexAtX(90, 100, 5), 4) +test('maps a drag percent to the tab under the finger', () => { + assert.equal(tabIndexAtPercent(10, 5), 0) + assert.equal(tabIndexAtPercent(30, 5), 1) + assert.equal(tabIndexAtPercent(50, 5), 2) + assert.equal(tabIndexAtPercent(70, 5), 3) + assert.equal(tabIndexAtPercent(90, 5), 4) }) -test('clamps drag positions outside the bar', () => { - assert.equal(tabIndexAtX(-10, 100, 5), 0) - assert.equal(tabIndexAtX(200, 100, 5), 4) - assert.equal(clampDragX(-5, 100), 0) - assert.equal(clampDragX(120, 100), 100) - assert.equal(clampDragX(Number.NaN, 100), 0) +test('clamps percent and maps out-of-range to edge tabs', () => { + assert.equal(clampPercent(-5), 0) + assert.equal(clampPercent(120), 100) + assert.equal(clampPercent(Number.NaN), 0) + assert.equal(tabIndexAtPercent(-5, 5), 0) + assert.equal(tabIndexAtPercent(120, 5), 4) +}) + +test('only treats a move as a drag beyond the threshold', () => { + assert.equal(hasDragged(100, 110, 12), false) + assert.equal(hasDragged(100, 112, 12), true) + assert.equal(hasDragged(100, 88, 12), true) }) test('derives the floating bar frame from window width', () => {