mine-drawer-shell.vue 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. <template>
  2. <view
  3. class="mine-drawer-stage"
  4. :class="{ 'mine-drawer-stage--open': open }"
  5. >
  6. <!-- 中间缝隙(强调左右两块分离) -->
  7. <view
  8. v-show="open"
  9. class="mine-drawer-gutter"
  10. :style="gutterStyle"
  11. />
  12. <!-- 右侧设置层 -->
  13. <view
  14. class="mine-drawer-panel"
  15. :class="{ 'mine-drawer-panel--open': open }"
  16. :style="panelLayerStyle"
  17. >
  18. <slot name="drawer" />
  19. </view>
  20. <!-- 左侧点击关闭热区 -->
  21. <view
  22. v-show="open"
  23. class="mine-drawer-dismiss"
  24. :style="dismissStyle"
  25. @tap="onDismiss"
  26. >
  27. <view class="mine-drawer-dismiss-inner">
  28. <text class="mine-drawer-dismiss-icon">‹</text>
  29. </view>
  30. <text class="mine-drawer-dismiss-tip">{{ dismissTip }}</text>
  31. </view>
  32. <!-- 主内容卡片 -->
  33. <view
  34. class="mine-drawer-main"
  35. :class="{ 'mine-drawer-main--open': open }"
  36. :style="mainLayerStyle"
  37. @touchstart="onMainTouchStart"
  38. @touchmove="onMainTouchMove"
  39. @touchend="onMainTouchEnd"
  40. @touchcancel="onMainTouchEnd"
  41. >
  42. <slot />
  43. </view>
  44. </view>
  45. </template>
  46. <script>
  47. const MAIN_SCALE = 0.837
  48. const MAIN_GAP_RPX = 32
  49. const CARD_INSET_RPX = 28
  50. /** 左侧预览 : 右侧抽屉 = 2.5 : 7.5(占满屏宽) */
  51. const PEEK_RATIO = 0.25
  52. const PANEL_RATIO = 0.75
  53. /**
  54. * 主屏平移:先贴到 25% 分界线,再额外左推 EXTRA,最后收回 BACK(往右)
  55. * 40rpx 收回 ≈ 屏宽 40/750;折算后 EXTRA 约 10%
  56. */
  57. const MAIN_EXTRA_SHIFT_RATIO = 0.1
  58. const MAIN_SHIFT_BACK_RPX = 40
  59. const CLOSE_DRAG_THRESHOLD = 56
  60. export default {
  61. name: 'MineDrawerShell',
  62. props: {
  63. open: {
  64. type: Boolean,
  65. default: false
  66. },
  67. dismissTip: {
  68. type: String,
  69. default: '返回'
  70. }
  71. },
  72. data() {
  73. return {
  74. winWidth: 375,
  75. panelWidthPx: 300,
  76. panelLeftPx: 94,
  77. gutterLeftPx: 86,
  78. peekWidthPx: 75,
  79. gapPx: 16,
  80. shiftBackPx: 20,
  81. cardInsetPx: 14,
  82. safeTop: 0,
  83. safeBottom: 0,
  84. dragOffset: 0,
  85. dragging: false,
  86. _touchStartX: 0,
  87. _touchStartY: 0,
  88. _touchAxis: ''
  89. }
  90. },
  91. computed: {
  92. panelLayerStyle() {
  93. const base = {
  94. paddingTop: `${this.safeTop}px`,
  95. paddingBottom: `${this.safeBottom}px`
  96. }
  97. if (!this.open) {
  98. return {
  99. ...base,
  100. width: `${this.panelWidthPx}px`
  101. }
  102. }
  103. return {
  104. ...base,
  105. left: `${this.panelLeftPx}px`,
  106. right: `${this.cardInsetPx}px`,
  107. width: 'auto',
  108. top: `${this.cardInsetPx}px`,
  109. bottom: `${this.cardInsetPx}px`,
  110. height: 'auto'
  111. }
  112. },
  113. gutterStyle() {
  114. return {
  115. left: `${this.gutterLeftPx}px`,
  116. width: `${this.gapPx}px`,
  117. top: `${this.cardInsetPx}px`,
  118. bottom: `${this.cardInsetPx}px`
  119. }
  120. },
  121. dismissStyle() {
  122. return { width: `${this.peekWidthPx}px` }
  123. },
  124. mainBaseShiftPx() {
  125. return Math.max(this.winWidth - this.peekWidthPx, 0)
  126. },
  127. mainExtraShiftPx() {
  128. return Math.round(this.winWidth * MAIN_EXTRA_SHIFT_RATIO)
  129. },
  130. mainTotalShiftPx() {
  131. const total = this.mainBaseShiftPx + this.mainExtraShiftPx - this.shiftBackPx
  132. return Math.max(total, 0)
  133. },
  134. mainShiftPx() {
  135. return this.mainTotalShiftPx
  136. },
  137. mainLayerStyle() {
  138. if (!this.open) return {}
  139. const shift = this.mainTotalShiftPx - this.dragOffset
  140. const style = {
  141. left: '0',
  142. right: '0',
  143. width: `${this.winWidth}px`,
  144. borderRadius: '28rpx',
  145. overflow: 'hidden',
  146. /* 必须先平移再缩放,否则 translate 在缩放坐标系里几乎不动 */
  147. transform: `translate3d(-${shift}px, 0, 0) scale(${MAIN_SCALE})`,
  148. transformOrigin: 'right center',
  149. top: `${this.cardInsetPx}px`,
  150. bottom: `${this.cardInsetPx}px`,
  151. height: 'auto',
  152. minHeight: '0'
  153. }
  154. if (this.dragging) {
  155. style.transition = 'none'
  156. }
  157. return style
  158. }
  159. },
  160. watch: {
  161. open(val) {
  162. if (!val) {
  163. this.dragOffset = 0
  164. this.dragging = false
  165. }
  166. this.togglePageScroll(val)
  167. }
  168. },
  169. created() {
  170. this.measureViewport()
  171. if (typeof uni.onWindowResize === 'function') {
  172. uni.onWindowResize(this.measureViewport)
  173. }
  174. },
  175. beforeDestroy() {
  176. if (typeof uni.offWindowResize === 'function') {
  177. uni.offWindowResize(this.measureViewport)
  178. }
  179. this.togglePageScroll(false)
  180. },
  181. methods: {
  182. measureViewport() {
  183. try {
  184. const info = uni.getSystemInfoSync()
  185. this.winWidth = info.windowWidth || 375
  186. this.safeTop = info.statusBarHeight || 0
  187. this.safeBottom = (info.safeAreaInsets && info.safeAreaInsets.bottom) || 0
  188. this.gapPx = uni.upx2px(MAIN_GAP_RPX)
  189. this.shiftBackPx = uni.upx2px(MAIN_SHIFT_BACK_RPX)
  190. this.cardInsetPx = uni.upx2px(CARD_INSET_RPX)
  191. this.peekWidthPx = Math.round(this.winWidth * PEEK_RATIO)
  192. this.panelLeftPx = this.peekWidthPx
  193. this.panelWidthPx = Math.round(this.winWidth * PANEL_RATIO)
  194. this.gutterLeftPx = Math.max(this.peekWidthPx - this.gapPx, 0)
  195. } catch (_) {}
  196. },
  197. onDismiss() {
  198. if (!this.open) return
  199. this.$emit('close')
  200. },
  201. onMainTouchStart(e) {
  202. if (!this.open || !e.touches || !e.touches.length) return
  203. this._touchStartX = e.touches[0].clientX
  204. this._touchStartY = e.touches[0].clientY
  205. this._touchAxis = ''
  206. this.dragging = false
  207. },
  208. onMainTouchMove(e) {
  209. if (!this.open || !e.touches || !e.touches.length) return
  210. const dx = e.touches[0].clientX - this._touchStartX
  211. const dy = e.touches[0].clientY - this._touchStartY
  212. if (!this._touchAxis) {
  213. if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return
  214. this._touchAxis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y'
  215. }
  216. if (this._touchAxis !== 'x') return
  217. if (dx <= 0) {
  218. this.dragOffset = 0
  219. return
  220. }
  221. this.dragging = true
  222. this.dragOffset = Math.min(dx, this.mainShiftPx)
  223. },
  224. onMainTouchEnd() {
  225. if (!this.open) return
  226. if (this.dragOffset >= CLOSE_DRAG_THRESHOLD) {
  227. this.$emit('close')
  228. }
  229. this.dragOffset = 0
  230. this.dragging = false
  231. this._touchAxis = ''
  232. },
  233. togglePageScroll(lock) {
  234. // #ifdef H5
  235. try {
  236. document.body.style.overflow = lock ? 'hidden' : ''
  237. } catch (_) {}
  238. // #endif
  239. }
  240. }
  241. }
  242. </script>
  243. <style scoped>
  244. .mine-drawer-stage {
  245. position: relative;
  246. min-height: 100vh;
  247. width: 100%;
  248. overflow-x: hidden;
  249. background-color: #f4f6fb;
  250. }
  251. .mine-drawer-stage--open {
  252. position: fixed;
  253. left: 0;
  254. top: 0;
  255. right: 0;
  256. bottom: 0;
  257. z-index: 10050;
  258. overflow: hidden;
  259. background-color: #d8dee8;
  260. }
  261. .mine-drawer-gutter {
  262. position: fixed;
  263. z-index: 4;
  264. pointer-events: none;
  265. border-radius: 8rpx;
  266. background-color: #d8dee8;
  267. }
  268. .mine-drawer-panel {
  269. position: fixed;
  270. top: 0;
  271. right: 0;
  272. height: 100vh;
  273. z-index: 2;
  274. box-sizing: border-box;
  275. display: flex;
  276. flex-direction: column;
  277. background: #f6f8fc;
  278. transform: translateX(100%);
  279. opacity: 0;
  280. visibility: hidden;
  281. pointer-events: none;
  282. transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  283. opacity 0.32s ease,
  284. visibility 0.42s,
  285. top 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  286. bottom 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  287. right 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  288. border-radius 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  289. box-shadow 0.42s cubic-bezier(0.32, 0.72, 0, 1);
  290. will-change: transform, opacity;
  291. }
  292. .mine-drawer-panel--open {
  293. z-index: 7;
  294. transform: translateX(0);
  295. opacity: 1;
  296. visibility: visible;
  297. pointer-events: all;
  298. border-radius: 28rpx;
  299. border: 2rpx solid #ffffff;
  300. overflow: hidden;
  301. box-shadow: 0 12rpx 48rpx rgba(15, 23, 42, 0.12);
  302. }
  303. .mine-drawer-dismiss {
  304. position: fixed;
  305. left: 0;
  306. top: 0;
  307. height: 100vh;
  308. z-index: 6;
  309. display: flex;
  310. flex-direction: column;
  311. align-items: center;
  312. justify-content: center;
  313. }
  314. .mine-drawer-dismiss-inner {
  315. width: 72rpx;
  316. height: 72rpx;
  317. border-radius: 50%;
  318. background: #ffffff;
  319. border: 1rpx solid #e5e7eb;
  320. display: flex;
  321. align-items: center;
  322. justify-content: center;
  323. box-shadow: 0 6rpx 20rpx rgba(15, 23, 42, 0.08);
  324. }
  325. .mine-drawer-dismiss-icon {
  326. font-size: 44rpx;
  327. color: #4b5563;
  328. line-height: 1;
  329. font-weight: 300;
  330. margin-right: 4rpx;
  331. }
  332. .mine-drawer-dismiss-tip {
  333. margin-top: 16rpx;
  334. font-size: 22rpx;
  335. color: #6b7280;
  336. letter-spacing: 2rpx;
  337. }
  338. .mine-drawer-main {
  339. position: relative;
  340. z-index: 3;
  341. min-height: 100vh;
  342. width: 100%;
  343. background-color: #f4f6fb;
  344. transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  345. border-radius 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  346. box-shadow 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  347. top 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  348. bottom 0.42s cubic-bezier(0.32, 0.72, 0, 1),
  349. height 0.42s cubic-bezier(0.32, 0.72, 0, 1);
  350. transform-origin: 100% 50%;
  351. will-change: transform;
  352. }
  353. .mine-drawer-main--open {
  354. position: fixed;
  355. left: 0;
  356. right: 0;
  357. z-index: 4;
  358. border-radius: 28rpx;
  359. border: 2rpx solid #ffffff;
  360. overflow: hidden;
  361. box-shadow: 0 12rpx 48rpx rgba(15, 23, 42, 0.14);
  362. }
  363. </style>