在 React 中优雅的使用长按事件
背景
之前有个需求是需要支持表单内文本的复制,我一想这还不简单?浏览器有自带的 API。可实际上大家都知道 Clipboard API
的兼容性不是很好。再者又出于对项目本身的考量(因为本身是几乎以表单为主的),不能需求说要加一个可复制的字段就再开发一次。
需求:
- 长按可复制文本
- 可复制 input、text、textarea、picker、div
- 支持复制 dform 的 input、text、textarea、picker、div,和普通 input、textarea
- 禁用或灰显时,也需支持复制
在移动端上复制似乎是个问题,咱也不能弄的满屏都是按钮,而且这种单独配置某个字段来实现的方式,是挺麻烦的,还丑。
于是乎就跟项目经理扯说那我搞个长按复制吧。也就有了这篇文章。
长按事件
说到长按事件,就需要扯出移动端的几个事件了:
- 按下时触发 ontouchstart
- 移动时触发 ontouchmove
- 手指离开时触发 ontouchend
那么思路就是:在 ontouchstart
时加上一个 timeout 去执行回调,ontouchmove
或 ontouchend
清除 timeout 即可。也就是说,超过指定的时间,长按回调执行一次。如果没有超过指定的时间,手指离开屏幕或手指移动,长按的回调不会执行。
interface ILongPressEventsProps {
onStartCallback: (
event: React.TouchEvent<
HTMLDivElement | HTMLTextAreaElement | HTMLInputElement
>['nativeEvent']['target'],
) => void;
onEndCallback?: (
event: React.TouchEvent<
HTMLDivElement | HTMLTextAreaElement | HTMLInputElement
>['nativeEvent']['target'],
) => void;
ms?: number;
}
type ILongPressStartMethod = (event: React.TouchEvent) => NodeJS.Timeout;
type ILongPressEndMethod = (event: React.TouchEvent) => void;
interface RLongPressEventsReturnTypes {
onTouchStart: ILongPressStartMethod;
onTouchMove: ILongPressEndMethod;
onTouchEnd: ILongPressEndMethod;
}
const longPressEvents = function ({
onStartCallback,
onEndCallback,
ms = 2000,
}: ILongPressEventsProps): RLongPressEventsReturnTypes {
let timeout: NodeJS.Timeout;
let target: EventTarget | null;
const start: ILongPressStartMethod = (event) => {
if (event.nativeEvent instanceof TouchEvent) target = event.nativeEvent.target;
return (timeout = setTimeout(() => onStartCallback(target), ms));
};
const stop: ILongPressEndMethod = (event) => {
timeout && window.clearTimeout(timeout); // 合成事件,要先 clear,否则报 warning
// 下边的其实可以不用,如果不需要结束回调的话
if (event.nativeEvent instanceof TouchEvent) target = event.nativeEvent.target;
onEndCallback?.(target);
};
return {
onTouchStart: start,
onTouchMove: stop,
onTouchEnd: stop,
};
};
只需要将这三个事件挂载到 div 上就好(如果是 pc 也可以替换为鼠标事件)。
<div {...longPressMethods}>hello world</div>
实现复制功能
考虑到兼容性,这边引入了 clipboard.js。
// data-clipboard-text 文本源,可参考 clipboard.js,使其隐藏,不手动点击这个按钮
<button id="copy_btn" data-clipboard-text="" style={{ display: 'none' }}></button>
function copyToClipboard(value: string | undefined) {
if (!value) return;
const clipboard = new ClipboardJS('#copy_btn');
clipboard.on('success', (e) => {
Toast.success('复制成功!', 1);
e.clearSelection();
});
clipboard.on('error', (e) => {
Toast.fail('复制失败!', 1);
});
const btn = document.querySelector('#copy_btn') as HTMLButtonElement;
btn?.setAttribute('data-clipboard-text', value);
btn?.click();
}
问题
直接调用复制方法 copyToClipboard
会复制失败,需要通过按钮手动触发。
解决方案
新增一个 prompt
,长按 setTimeout
回调弹出,点击确认按钮触发复制。
使用 prompt
有个优点,可以将取得的文本回填到 prompt
的输入框,可以做更改,再复制。
function showCopyPrompt(defaultValue: string | undefined) {
Modal.prompt(
'点击以复制文本',
'',
[{ text: '取消' }, { text: '确认', onPress: copyToClipboard }],
'default',
defaultValue,
);
}
HOC
抽离出一个 HOC,接收 children,让 children 内部支持复制。
import React from 'react';
import ClipboardJS from 'clipboard';
import { Modal, Toast } from 'antd-mobile';
import { longPressEvents } from '@/utils';
interface CopyToClipboardWrapperProps {}
function copyToClipboard(value: string | undefined) {
if (!value) return;
const clipboard = new ClipboardJS('#copy_btn');
clipboard.on('success', (e) => {
Toast.success('复制成功!', 1);
e.clearSelection();
});
clipboard.on('error', (e) => {
Toast.fail('复制失败!', 1);
});
// 通过手动触发 prompt 的 positive 回调去触发 copy_btn 的自动点击,很呆,但有用
const btn = document.querySelector('#copy_btn') as HTMLButtonElement;
btn?.setAttribute('data-clipboard-text', value);
btn?.click();
}
function showCopyPrompt(defaultValue: string | undefined) {
Modal.prompt(
'点击以复制文本',
'',
[{ text: '取消' }, { text: '确认', onPress: copyToClipboard }],
'default',
defaultValue,
);
}
const CopyToClipboardWrapper: React.FC<CopyToClipboardWrapperProps> = ({ children }) => {
const startCallback = (ele: EventTarget | null) => {
let text = '';
const isDFormDiv = (ele as HTMLDivElement)?.className?.includes?.('alitajs-dform');
const type = (ele as HTMLTextAreaElement | HTMLInputElement)?.tagName;
if (isDFormDiv) {
text =
(ele as HTMLDivElement)?.innerText ||
(ele as HTMLTextAreaElement | HTMLInputElement)?.value;
} else {
if (type === 'INPUT' || type === 'TEXTAREA') {
text = (ele as HTMLTextAreaElement | HTMLInputElement)?.value;
} else {
// 普通标签,比如 div、p、span 等
// @ts-ignore
if (ele?.children?.length) return;
text = (ele as HTMLDivElement)?.innerText;
}
}
showCopyPrompt(text);
};
const longPressMethods = longPressEvents(startCallback);
return (
<>
<button id="copy_btn" data-clipboard-text="" style={{ display: 'none' }}></button>
<div {...longPressMethods}>{children}</div>
</>
);
};
export default CopyToClipboardWrapper;
使用
<CopyToClipboardWrapper>
<DynamicForm {...formProps} />
</CopyToClipboardWrapper>
<CopyToClipboardWrapper>
<Form {...formProps}>
<List>
<NomarInput fieldProps="username" required placeholder="请输入" title="用户名" />
<NomarRadio fieldProps="gender" title="性别" data={genderList} />
<NomarDatePicker fieldProps="date" placeholder="请选择" title="出生年月" />
<NomarPicker
fieldProps="weather"
placeholder="请选择"
title="天气"
data={weatherList}
/>
<MultiplePicker
fieldProps="motion"
placeholder="请选择"
title="特长"
data={motionList}
/>
{/* ...... */}
</List>
</Form>
<Button onClick={() => form.submit()}>提交</Button>
</CopyToClipboardWrapper>
让它更加实用
既然我们可以使用长按事件来做复制了,还将长按复制给封装成了一个 HOC,那么长按选择或是拖拽呢?自然也就可以实现了。
按照类似的思路,我做了一个长按选择的 layout,支持多选和单选。有兴趣的小伙伴可以看看。
<MultiSelectLayout
mode="multi"
namespace="demo"
selectedListKey="selectData"
hidden={!data?.length}
longPress
>
<Card>
{data?.map((item: any, index: number) => {
return (
<MultiSelectLayout.MultiSelectRow key={item?.id} rowItem={item}>
<div>{item?.name}</div>
</MultiSelectLayout.MultiSelectRow>
);
})}
</Card>
</MultiSelectLayout>
关键代码,有删减
MultiSelectLayout
const dispatch = useDispatch();
const childs = React.Children.toArray(children);
const [selectedRows, setSelectedRows] = useState<Array<unknown>>([]);
useEffect(() => {
dispatch!({
type: `${namespace}/save`,
payload: {
[selectedListKey]: selectedRows,
},
});
}, [selectedRows.length]);
<MultiSelectContext.Provider value={{ selectedRows, setSelectedRows, disabled, mode }}>
<div className={prefixCls} {...reset}>
{childs.map((child) => {
if (!React.isValidElement(child)) return;
const props = {
...child.props,
longPress,
};
return React.cloneElement(child, props);
})}
</div>
</MultiSelectContext.Provider>
MultiSelectLayout.MultiSelectRow
const [selected, toggle] = useToggle(false);
const fn = {
toggleSelect() {
if (disabled) return;
toggle();
if (mode === 'multi') {
fn.mutiSelect(rowItem);
} else {
fn.singleSelect(rowItem);
}
afterSelect?.(rowItem, [...selectedRows, rowItem]);
},
singleSelect(rowItem: MultiSelectRowProps['rowItem']) {
if (fn.isSelected()) {
setSelectedRows([]);
} else {
setSelectedRows([rowItem]);
}
},
mutiSelect(rowItem: MultiSelectRowProps['rowItem']) {
const foundResult = fn.isSelected();
if (foundResult) {
setSelectedRows!((prev) => prev.filter((item) => item.id !== rowItem.id));
} else {
setSelectedRows([...selectedRows, rowItem]);
}
},
isSelected() {
return selectedRows!.find((item) => item.id === rowItem.id);
},
onPress() {
const execResult = beforeSelect?.();
if (beforeSelect) {
if (execResult) {
fn.toggleSelect();
}
} else {
fn.toggleSelect();
}
},
};
useEffect(() => {
if (selected) {
onSelect?.(rowItem);
} else {
onUnselect?.(rowItem);
}
}, [selected]);
<div
className={`${prefixCls}-content`}
{...(props?.longPress
? window.longPressEvents(
(ele: any) => {
fn.onPress();
},
() => {},
500,
)
: {})}
>
{children}
</div>
大力支持
最后
感谢各位耐心看到这里 ❤️