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)
This commit is contained in:
CatsJuice 2024-12-02 08:42:02 +00:00
parent 9b4cd83a07
commit 4744896031
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
7 changed files with 330 additions and 57 deletions

View File

@ -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(
<SafeArea
bottom
className={styles.appTabs}
@ -81,7 +82,8 @@ export const AppTabs = ({ background }: { background?: string }) => {
}
})}
</ul>
</SafeArea>
</SafeArea>,
document.body
);
};

View File

@ -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 = () => {
<ArrowRightSmallIcon fontSize={22} />
</RowLayout>
</SettingGroup>
<Modal
animation="slideRight"
<SwipeDialog
open={open}
onOpenChange={setOpen}
fullScreen
contentOptions={{ className: styles.dialog }}
withoutCloseButton
title="Experimental Features"
>
<ExperimentalFeatureList onBack={() => setOpen(false)} />
</Modal>
<ExperimentalFeatureList />
</SwipeDialog>
</>
);
};
const ExperimentalFeatureList = ({ onBack }: { onBack: () => void }) => {
const ExperimentalFeatureList = () => {
const featureFlagService = useService(FeatureFlagService);
return (
<div className={styles.root}>
<PageHeader back={!!onBack} backAction={onBack} className={styles.header}>
<span className={styles.dialogTitle}>Experimental Features</span>
</PageHeader>
<Scrollable.Root className={styles.scrollArea}>
<Scrollable.Viewport>
<ul className={styles.content}>
{Object.keys(AFFINE_FLAGS).map(key => (
<ExperimentalFeaturesItem
key={key}
flag={featureFlagService.flags[key as keyof AFFINE_FLAGS]}
/>
))}
</ul>
</Scrollable.Viewport>
<Scrollable.Scrollbar orientation="vertical" />
</Scrollable.Root>
</div>
<ul className={styles.content}>
{Object.keys(AFFINE_FLAGS).map(key => (
<ExperimentalFeaturesItem
key={key}
flag={featureFlagService.flags[key as keyof AFFINE_FLAGS]}
/>
))}
</ul>
);
};

View File

@ -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',

View File

@ -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 (
<ConfigModal
<SwipeDialog
title={t['com.affine.mobile.setting.header-title']()}
open
onOpenChange={() => close()}
onBack={close}
>
<MobileSetting />
</ConfigModal>
</SwipeDialog>
);
// return (
// <ConfigModal
// title={t['com.affine.mobile.setting.header-title']()}
// open
// onOpenChange={() => close()}
// onBack={close}
// >
// <MobileSetting />
// </ConfigModal>
// );
};

View File

@ -8,6 +8,7 @@ export const root = style({
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: '24px 16px',
});
export const baseSettingItem = style({

View File

@ -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,
});

View File

@ -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<RefObject<HTMLElement>>;
}>({
stack: [],
});
export const SwipeDialog = ({
title,
children,
open,
triggerSize = 10,
onOpenChange,
}: SwipeDialogProps) => {
const swiperTriggerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(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 (
<SwipeDialogContext.Provider value={{ stack: [...stack, dialogRef] }}>
{createPortal(
<div className={styles.root}>
<div className={styles.overlay} ref={overlayRef} />
<div role="dialog" className={styles.dialog} ref={dialogRef}>
<div className={styles.content}>
<PageHeader
back
backAction={animateClose}
className={styles.header}
>
<span className={styles.dialogTitle}>{title}</span>
</PageHeader>
<Scrollable.Root className={styles.scrollArea}>
<Scrollable.Viewport>{children}</Scrollable.Viewport>
<Scrollable.Scrollbar orientation="vertical" />
</Scrollable.Root>
</div>
<div
ref={swiperTriggerRef}
className={styles.swipeBackTrigger}
style={assignInlineVars({
[styles.triggerSizeVar]: `${triggerSize}px`,
})}
/>
</div>
</div>,
document.body
)}
</SwipeDialogContext.Provider>
);
};