From 474489603143b4040cf39f49d36b98c1277a188d Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Mon, 2 Dec 2024 08:42:02 +0000 Subject: [PATCH] feat(mobile): a basic swipeable dialog for setting (#8923) ![CleanShot 2024-12-02 at 15.42.42.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/0a6e5700-dd25-4f36-824a-434603e01379.gif) --- .../src/mobile/components/app-tabs/index.tsx | 6 +- .../dialogs/setting/experimental/index.tsx | 43 ++-- .../setting/experimental/styles.css.ts | 24 +- .../core/src/mobile/dialogs/setting/index.tsx | 18 +- .../src/mobile/dialogs/setting/style.css.ts | 1 + .../dialogs/setting/swipe-dialog.css.ts | 57 +++++ .../mobile/dialogs/setting/swipe-dialog.tsx | 238 ++++++++++++++++++ 7 files changed, 330 insertions(+), 57 deletions(-) create mode 100644 packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.css.ts create mode 100644 packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx diff --git a/packages/frontend/core/src/mobile/components/app-tabs/index.tsx b/packages/frontend/core/src/mobile/components/app-tabs/index.tsx index cd8f19b3b1..55e54dd69b 100644 --- a/packages/frontend/core/src/mobile/components/app-tabs/index.tsx +++ b/packages/frontend/core/src/mobile/components/app-tabs/index.tsx @@ -7,6 +7,7 @@ import { AllDocsIcon, MobileHomeIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import React from 'react'; +import { createPortal } from 'react-dom'; import type { Location } from 'react-router-dom'; import { VirtualKeyboardService } from '../../modules/virtual-keyboard/services/virtual-keyboard'; @@ -58,7 +59,7 @@ export const AppTabs = ({ background }: { background?: string }) => { const virtualKeyboardService = useService(VirtualKeyboardService); const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$); - return ( + return createPortal( { } })} - + , + document.body ); }; diff --git a/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx index d28cd896f1..be57480425 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx @@ -1,5 +1,4 @@ -import { Modal, Scrollable, Switch } from '@affine/component'; -import { PageHeader } from '@affine/core/mobile/components'; +import { Switch } from '@affine/component'; import { useI18n } from '@affine/i18n'; import { ArrowRightSmallIcon } from '@blocksuite/icons/rc'; import { @@ -13,6 +12,7 @@ import { useCallback, useState } from 'react'; import { SettingGroup } from '../group'; import { RowLayout } from '../row.layout'; +import { SwipeDialog } from '../swipe-dialog'; import * as styles from './styles.css'; export const ExperimentalFeatureSetting = () => { @@ -28,42 +28,29 @@ export const ExperimentalFeatureSetting = () => { - - setOpen(false)} /> - + + ); }; -const ExperimentalFeatureList = ({ onBack }: { onBack: () => void }) => { +const ExperimentalFeatureList = () => { const featureFlagService = useService(FeatureFlagService); return ( -
- - Experimental Features - - - -
    - {Object.keys(AFFINE_FLAGS).map(key => ( - - ))} -
-
- -
-
+
    + {Object.keys(AFFINE_FLAGS).map(key => ( + + ))} +
); }; diff --git a/packages/frontend/core/src/mobile/dialogs/setting/experimental/styles.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/experimental/styles.css.ts index b24de245cc..a2dcd061a1 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/experimental/styles.css.ts +++ b/packages/frontend/core/src/mobile/dialogs/setting/experimental/styles.css.ts @@ -1,29 +1,7 @@ -import { - bodyEmphasized, - bodyRegular, - footnoteRegular, -} from '@toeverything/theme/typography'; +import { bodyRegular, footnoteRegular } from '@toeverything/theme/typography'; import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; -export const dialog = style({ - padding: '0 !important', - background: cssVarV2('layer/background/mobile/primary'), -}); -export const root = style({ - display: 'flex', - flexDirection: 'column', - height: '100dvh', -}); -export const header = style({ - background: `${cssVarV2('layer/background/mobile/primary')} !important`, -}); -export const dialogTitle = style([bodyEmphasized, {}]); -export const scrollArea = style({ - height: 0, - flex: 1, -}); - export const content = style({ padding: '24px 16px', display: 'flex', diff --git a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx index 7c22bf6cf0..65028036cb 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx @@ -1,4 +1,3 @@ -import { ConfigModal } from '@affine/core/components/mobile'; import { AuthService } from '@affine/core/modules/cloud'; import type { DialogComponentProps, @@ -13,6 +12,7 @@ import { AppearanceGroup } from './appearance'; import { ExperimentalFeatureSetting } from './experimental'; import { OthersGroup } from './others'; import * as styles from './style.css'; +import { SwipeDialog } from './swipe-dialog'; import { UserProfile } from './user-profile'; import { UserUsage } from './user-usage'; @@ -38,13 +38,23 @@ export const SettingDialog = ({ const t = useI18n(); return ( - close()} - onBack={close} > - + ); + + // return ( + // close()} + // onBack={close} + // > + // + // + // ); }; diff --git a/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts index fe74c0bcd1..cbf0a168b2 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts +++ b/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts @@ -8,6 +8,7 @@ export const root = style({ display: 'flex', flexDirection: 'column', gap: 16, + padding: '24px 16px', }); export const baseSettingItem = style({ diff --git a/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.css.ts new file mode 100644 index 0000000000..e260825fcf --- /dev/null +++ b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.css.ts @@ -0,0 +1,57 @@ +import { cssVar } from '@toeverything/theme'; +import { bodyEmphasized } from '@toeverything/theme/typography'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { createVar, style } from '@vanilla-extract/css'; + +export const root = style({ + position: 'fixed', + top: 0, + left: 0, + zIndex: cssVar('zIndexModal'), + width: '100dvw', + height: '100dvh', +}); +export const overlay = style({ + position: 'absolute', + width: '100%', + height: '100%', + left: 0, + top: 0, + background: 'transparent', +}); +export const dialog = style([ + overlay, + { + padding: 0, + background: cssVarV2('layer/background/mobile/primary'), + // initial state, + transform: 'translateX(100%)', + }, +]); + +export const content = style({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + height: '100dvh', +}); + +export const header = style({ + background: `${cssVarV2('layer/background/mobile/primary')} !important`, +}); + +export const dialogTitle = style([bodyEmphasized, {}]); +export const scrollArea = style({ + height: 0, + flex: 1, +}); + +export const triggerSizeVar = createVar('triggerSize'); +export const swipeBackTrigger = style({ + position: 'absolute', + top: 0, + left: 0, + width: triggerSizeVar, + height: '100%', + zIndex: 1, +}); diff --git a/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx new file mode 100644 index 0000000000..02a3f1649a --- /dev/null +++ b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx @@ -0,0 +1,238 @@ +import { Scrollable } from '@affine/component'; +import { PageHeader } from '@affine/core/mobile/components'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import anime from 'animejs'; +import { + createContext, + type PropsWithChildren, + type RefObject, + useCallback, + useContext, + useEffect, + useRef, +} from 'react'; +import { createPortal } from 'react-dom'; + +import { SwipeHelper } from '../../utils'; +import * as styles from './swipe-dialog.css'; + +export interface SwipeDialogProps extends PropsWithChildren { + triggerSize?: number; + title?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +const overlayOpacityRange = [0, 0.1]; + +const tick = ( + overlay: HTMLDivElement, + dialog: HTMLDivElement, + prev: HTMLElement | null, + deltaX: number +) => { + const limitedDeltaX = Math.min(overlay.clientWidth, Math.max(0, deltaX)); + const percent = limitedDeltaX / overlay.clientWidth; + const opacity = + overlayOpacityRange[1] - + (overlayOpacityRange[1] - overlayOpacityRange[0]) * percent; + overlay.style.background = `rgba(0, 0, 0, ${opacity})`; + dialog.style.transform = `translateX(${limitedDeltaX}px)`; + + const prevEl = prev ?? document.querySelector('#app'); + if (prevEl) { + const range = [-80, 0]; + const offset = range[0] + (range[1] - range[0]) * percent; + prevEl.style.transform = `translateX(${offset}px)`; + } +}; +const reset = ( + overlay: HTMLDivElement, + dialog: HTMLDivElement, + prev: HTMLElement | null +) => { + overlay && (overlay.style.background = 'transparent'); + dialog && (dialog.style.transform = 'unset'); + const prevEl = prev ?? document.querySelector('#app'); + if (prevEl) { + prevEl.style.transform = 'unset'; + } +}; + +const getAnimeProxy = ( + overlay: HTMLDivElement, + dialog: HTMLDivElement, + prev: HTMLElement | null, + init: number +) => { + return new Proxy( + { deltaX: init }, + { + set(target, key, value) { + if (key === 'deltaX') { + target.deltaX = value; + tick(overlay, dialog, prev, value); + return true; + } + return false; + }, + } + ); +}; + +const cancel = ( + overlay: HTMLDivElement, + dialog: HTMLDivElement, + prev: HTMLElement | null, + deltaX: number, + complete?: () => void +) => { + anime({ + targets: getAnimeProxy( + overlay, + dialog, + prev, + Math.min(overlay.clientWidth, Math.max(0, deltaX)) + ), + deltaX: 0, + easing: 'easeInOutSine', + duration: 230, + complete: () => { + complete?.(); + setTimeout(() => { + reset(overlay, dialog, prev); + }, 0); + }, + }); +}; + +const close = ( + overlay: HTMLDivElement, + dialog: HTMLDivElement, + prev: HTMLElement | null, + deltaX: number, + complete?: () => void +) => { + anime({ + targets: getAnimeProxy( + overlay, + dialog, + prev, + Math.min(overlay.clientWidth, Math.max(0, deltaX)) + ), + deltaX: overlay.clientWidth, + easing: 'easeInOutSine', + duration: 230, + complete: () => { + complete?.(); + setTimeout(() => { + reset(overlay, dialog, prev); + }, 0); + }, + }); +}; + +const SwipeDialogContext = createContext<{ + stack: Array>; +}>({ + stack: [], +}); + +export const SwipeDialog = ({ + title, + children, + open, + triggerSize = 10, + onOpenChange, +}: SwipeDialogProps) => { + const swiperTriggerRef = useRef(null); + const overlayRef = useRef(null); + const dialogRef = useRef(null); + + const { stack } = useContext(SwipeDialogContext); + const prev = stack[stack.length - 1]?.current; + + const handleClose = useCallback(() => { + onOpenChange?.(false); + }, [onOpenChange]); + + const animateClose = useCallback(() => { + const overlay = overlayRef.current; + const dialog = dialogRef.current; + if (overlay && dialog) { + close(overlay, dialog, prev, 0, handleClose); + } else { + handleClose(); + } + }, [handleClose, prev]); + + useEffect(() => { + if (!open) return; + const overlay = overlayRef.current; + const dialog = dialogRef.current; + const swipeBackTrigger = swiperTriggerRef.current; + if (!overlay || !dialog || !swipeBackTrigger) return; + + const swipeHelper = new SwipeHelper(); + return swipeHelper.init(swipeBackTrigger, { + preventScroll: true, + onSwipeStart: () => {}, + onSwipe({ deltaX }) { + tick(overlay, dialog, prev, deltaX); + }, + onSwipeEnd: ({ deltaX }) => { + const shouldClose = deltaX > overlay.clientWidth * 0.2; + if (shouldClose) { + close(overlay, dialog, prev, deltaX, handleClose); + } else { + cancel(overlay, dialog, prev, deltaX); + } + }, + }); + }, [handleClose, open, prev]); + + useEffect(() => { + if (!open) return; + const overlay = overlayRef.current; + const dialog = dialogRef.current; + if (overlay && dialog) { + cancel(overlay, dialog, prev, overlay.clientWidth); + } + }, [open, prev]); + + if (!open) return null; + + return ( + + {createPortal( +
+
+
+
+ + {title} + + + + {children} + + +
+
+
+
, + document.body + )} + + ); +};