Taro实现自定义showModal
Modal.tsx
import { Text, View } from "@tarojs/components"
import { document } from "@tarojs/runtime"
import { createElement, type ReactNode, useEffect, useRef, useState } from "react"
import { lockScroll, mountPortal, unlockScroll, unmountPortal } from "../utils"
import "./modal.less"
const modalSelectorSet = new Set<string>()
const DEFAULT_MODAL_ID = "#modal"
interface ModalOptions {
/**
* 标题
*/
title?: string;
/**
* 内容
*/
content: ReactNode;
/**
* 是否显示透明蒙层
*/
mask?: boolean;
/**
* 确定的回调函数
*/
onConfirm?: () => void;
/**
* 取消的回调函数
*/
onCancel?: () => void;
[key: string]: any
}
interface ModalProps extends ModalOptions {
open?: boolean
id?: string
}
function Modal(props: ModalProps) {
const { open, id, content, mask = true, title, onConfirm, onCancel } = props
const [visible, setVisible] = useState(open)
const [isAnimating, setIsAnimating] = useState(false)
const closeTimerRef = useRef<NodeJS.Timeout | null>(null)
const isMountedRef = useRef(true)
// 处理确认按钮点击
function handleConfirm() {
if (isAnimating) return // 防止动画期间重复点击
setIsAnimating(true)
onConfirm && onConfirm()
handleClose()
}
// 处理取消按钮点击
function handleCancel() {
if (isAnimating) return // 防止动画期间重复点击
setIsAnimating(true)
onCancel && onCancel()
handleClose()
}
/**
* 关闭弹窗
*/
function handleClose() {
// 清理之前的定时器
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
}
closeTimerRef.current = setTimeout(() => {
// 检查组件是否仍然挂载
if (isMountedRef.current) {
setVisible(false)
setIsAnimating(false)
// 恢复背景滚动
unlockScroll()
}
}, 300) // 与CSS动画时长保持一致
}
useEffect(() => {
if (open) {
setVisible(true)
setIsAnimating(false)
// 禁止背景滚动
lockScroll()
}
}, [open])
// 组件卸载时清理定时器
useEffect(() => {
return () => {
isMountedRef.current = false
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}
}, [])
if (!visible) return null
return (
<View
className={`modal-container ${isAnimating ? 'modal-closing' : ''}`}
id={id}
style={{ backgroundColor: mask ? 'rgba(0, 0, 0, 0.6)' : 'transparent' }}
>
<View className='modal-content' catchMove>
{
title && (
<View className='modal-header'>
<View className='modal-title'>
{title}
</View>
</View>
)
}
<View className='modal-body'>
{content}
</View>
<View className='modal-footer'>
<View className='modal-footer-btn confirm' onClick={handleConfirm}>
<Text className='modal-footer-btn-text'>
确定
</Text>
</View>
<View className='modal-footer-btn-line'></View>
<View className='modal-footer-btn cancel' onClick={handleCancel}>
<Text className='modal-footer-btn-text'>
取消
</Text>
</View>
</View>
</View>
</View >
)
}
/**
* 显示 Modal
* @param options
* @returns
*/
Modal.showMoal = (options: ModalOptions) => {
const { onConfirm, onCancel, ...rest } = options || {}
const toastId = DEFAULT_MODAL_ID
const hasExistingToast = modalSelectorSet.has(toastId)
if (hasExistingToast) return toastId
// 添加Modal标识
modalSelectorSet.add(toastId)
try {
const viewDom = document.createElement("view")
// 创建Modal组件实例
const ModalElement = createElement(Modal, {
...rest,
open: true,
id: toastId,
onConfirm: () => {
setTimeout(() => {
onConfirm && onConfirm()
modalSelectorSet.delete(toastId)
unmountPortal(viewDom)
}, 300)
},
onCancel: () => {
setTimeout(() => {
onCancel && onCancel()
modalSelectorSet.delete(toastId)
unmountPortal(viewDom)
}, 300)
}
})
// 挂载Modal组件
mountPortal(ModalElement, viewDom)
} catch (error) {
console.error('Modal.open执行出错:', error)
return null
}
}
export default ModalModal.less
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
/* 添加背景遮罩动画 */
animation: modalBackdropFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background-color: rgba(0, 0, 0, 0.5);
/* 确保模态框容器不滚动 */
overflow: hidden;
/* 防止触摸滚动 */
touch-action: none;
/* 关闭动画状态 */
&.modal-closing {
animation: modalBackdropFadeOut 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.modal-content {
animation: modalContentSlideOut 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
.modal-content {
min-width: 70%;
max-width: 80%;
background-color: #fff;
border-radius: 16px;
/* 优化内容动画:添加缩放和位移效果 */
animation: modalContentSlideIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
/* 添加硬件加速 */
transform: translateZ(0);
will-change: transform, opacity;
/* 确保内容区域可以滚动(如果需要) */
max-height: 80vh;
overflow-y: auto;
.modal-header {
padding: 24px;
display: flex;
justify-content: center;
align-items: center;
.modal-title {
font-size: 32px;
line-height: 40px;
font-weight: 500;
}
}
.modal-body {
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
min-height: 120px;
}
.modal-footer {
display: flex;
align-items: center;
position: relative;
height: 88px;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
height: 1px;
background-color: #E5E5E5;
}
.modal-footer-btn {
flex: 1;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
/* 添加按钮点击动画 */
transition: background-color 0.2s ease;
&:active {
background-color: rgba(0, 0, 0, 0.05);
}
}
.modal-footer-btn-line {
width: 1px;
height: 100%;
background-color: #E5E5E5;
}
.modal-footer-btn.confirm {
height: 100%;
.modal-footer-btn-text {
font-size: 32px;
line-height: 40px;
font-weight: 500;
color: #f60;
}
}
.modal-footer-btn.cancel {
height: 100%;
.modal-footer-btn-text {
font-size: 32px;
line-height: 40px;
color: #000;
}
}
}
}
}
/* 背景遮罩淡入动画 */
@keyframes modalBackdropFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 背景遮罩淡出动画 */
@keyframes modalBackdropFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* 模态框内容滑入动画 - 优化版本 */
@keyframes modalContentSlideIn {
0% {
opacity: 0;
transform: scale(0.8) translateY(20px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 模态框内容滑出动画 */
@keyframes modalContentSlideOut {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.8) translateY(20px);
}
}utils.ts
import { getCurrentPages } from "@tarojs/taro"
import { render, unmountComponentAtNode } from "@tarojs/react"
import { document, TaroNode, type TaroElement } from "@tarojs/runtime"
import { ReactNode } from "react"
const portalViewMap: Map<string, TaroElement> = new Map()
const PORTAL_VIEW_ID = "portal_view"
// 滚动锁定相关变量
let scrollLockCount = 0
let originalBodyStyle = ''
/**
* 禁止背景滚动
*/
export const lockScroll = () => {
scrollLockCount++
if (scrollLockCount === 1) {
// 保存原始样式
const body = document.body
originalBodyStyle = body.style.cssText
// 设置禁止滚动样式
body.style.cssText = `
${originalBodyStyle}
overflow: hidden !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
`
}
}
/**
* 恢复背景滚动
*/
export const unlockScroll = () => {
scrollLockCount = Math.max(0, scrollLockCount - 1)
if (scrollLockCount === 0) {
const body = document.body
body.style.cssText = originalBodyStyle
}
}
export function getPagePath() {
const currentPages = getCurrentPages()
const currentPage = currentPages[currentPages.length - 1]
const path = currentPage.$taroPath
return path
}
// 获取或创建 Portal 容器
function getPortalContainer(path: string): TaroElement {
if (!portalViewMap.has(path)) {
const portalView = document.createElement("view")
portalView.setAttribute("id", PORTAL_VIEW_ID)
const pageElement = document.getElementById(path)
if (pageElement) {
pageElement.appendChild(portalView)
portalViewMap.set(path, portalView)
} else {
console.error("cannot find page element")
}
}
return portalViewMap.get(path)!
}
// 挂载 Portal 内容
export function mountPortal(children: ReactNode, dom: TaroElement): TaroElement {
const path = getPagePath()
const portalContainer = getPortalContainer(path)
// 直接渲染内容到容器中
render(children, dom)
portalContainer.appendChild(dom)
return portalContainer
}
// 清理 Portal 内容
export function unmountPortal(dom: TaroNode) {
const path = getPagePath()
const pageElement = document.getElementById(path)
unmountComponentAtNode(dom)
if (pageElement) {
dom.parentElement?.removeChild(dom)
} else {
console.error("cannot find page element")
}
}
评论
匿名评论
隐私政策
你无需删除空行,直接评论以获取最佳展示效果