Skip to content

Commit 18f3313

Browse files
committed
feat: 书籍列表动画优化
1 parent f56cd22 commit 18f3313

2 files changed

Lines changed: 236 additions & 84 deletions

File tree

src/pages/home/BookLibrary/index.jsx

Lines changed: 148 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import React, { useState, useRef, useCallback } from 'react'
1+
import React, { useState, useRef, useLayoutEffect } from 'react'
22
import { createPortal } from 'react-dom'
33
import { Input, Tag } from 'antd'
44
import { BookOutlined, ReadOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
5-
import { AnimatePresence, motion } from 'framer-motion'
65
import InteractiveBook from '@stateless/InteractiveBook'
76
import styles from './index.module.less'
87

@@ -281,6 +280,12 @@ const bookLibraryData = [
281280

282281
const allCategories = ['All', ...Array.from(new Set(bookLibraryData.map((b) => b.category)))]
283282

283+
// ─── 弹窗行为配置 ─────────────────────────────────
284+
const OVERLAY_CONFIG = {
285+
showCloseButton: true, // 是否显示关闭按钮
286+
maskClosable: true, // 点击蒙层是否关闭
287+
}
288+
284289
// ─── BookLibrary 组件 ────────────────────────────
285290
export default function BookLibrary() {
286291
const [activeCategory, setActiveCategory] = useState('All')
@@ -297,6 +302,9 @@ export default function BookLibrary() {
297302
const cardRefsMap = useRef({})
298303
const overlayContentRef = useRef(null)
299304
const maskRef = useRef(null)
305+
const flySourceRef = useRef(null)
306+
const openCleanupRef = useRef(null)
307+
const selectedBookRef = useRef(null)
300308

301309
const filteredBooks = bookLibraryData.filter((book) => {
302310
const matchCategory = activeCategory === 'All' || book.category === activeCategory
@@ -309,39 +317,103 @@ export default function BookLibrary() {
309317

310318
const openBook = (book) => {
311319
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
320+
if (openCleanupRef.current) clearTimeout(openCleanupRef.current)
312321
closePhaseRef.current = null
322+
323+
// 在 setState 之前捕获源卡片的位置
324+
const cardEl = cardRefsMap.current[book.id]
325+
flySourceRef.current = cardEl ? cardEl.getBoundingClientRect() : null
326+
313327
setSelectedBook(book)
328+
selectedBookRef.current = book
314329
setDialogOpen(true)
315330
setBookOpen(true)
316-
// 重置 DOM 样式(防止上次飞回残留)
331+
}
332+
333+
// ─── 飞出动画:卡片位置 → 屏幕中心(useLayoutEffect 在 paint 前执行) ───
334+
useLayoutEffect(() => {
335+
if (!dialogOpen || !selectedBook || !flySourceRef.current) return
336+
337+
const contentEl = overlayContentRef.current
338+
const maskEl = maskRef.current
339+
const sourceRect = flySourceRef.current
340+
flySourceRef.current = null
341+
342+
if (!contentEl) return
343+
344+
// 计算卡片中心 → 内容中心 的偏移量
345+
const contentRect = contentEl.getBoundingClientRect()
346+
const tx = sourceRect.left + sourceRect.width / 2 - (contentRect.left + contentRect.width / 2)
347+
const ty = sourceRect.top + sourceRect.height / 2 - (contentRect.top + contentRect.height / 2)
348+
const ts = Math.max(sourceRect.width / contentRect.width, 0.12)
349+
350+
// ── 第 1 帧:无过渡,直接定位到卡片位置(小 + 透明) ──
351+
contentEl.style.transition = 'none'
352+
contentEl.style.transform = `translate(${tx}px, ${ty}px) scale(${ts})`
353+
contentEl.style.opacity = '0'
354+
contentEl.style.overflow = 'hidden'
355+
contentEl.style.borderRadius = '12px'
356+
357+
if (maskEl) {
358+
maskEl.style.transition = 'none'
359+
maskEl.style.opacity = '0'
360+
}
361+
362+
// 强制重排,提交初始状态到渲染管线
363+
364+
contentEl.offsetHeight
365+
366+
// ── 第 2 帧:启用过渡,飞向中心(逐渐放大 + 淡入) ──
317367
requestAnimationFrame(() => {
318-
const el = overlayContentRef.current
319-
const mask = maskRef.current
320-
if (el) {
321-
el.style.transition = ''
322-
el.style.transform = ''
323-
el.style.opacity = ''
324-
el.style.overflow = ''
325-
el.style.borderRadius = ''
368+
contentEl.style.transition =
369+
'transform 0.55s cubic-bezier(0.22,1,0.36,1), ' + 'opacity 0.4s ease, ' + 'border-radius 0.45s ease'
370+
contentEl.style.transform = 'translate(0,0) scale(1)'
371+
contentEl.style.opacity = '1'
372+
contentEl.style.borderRadius = '0px'
373+
374+
if (maskEl) {
375+
maskEl.style.transition = 'opacity 0.4s ease'
376+
maskEl.style.opacity = '1'
326377
}
327-
if (mask) {
328-
mask.style.transition = ''
329-
mask.style.opacity = ''
378+
379+
// 触发分段渐变(顺向)
380+
const seg = contentEl.querySelector('.segmentGradient')
381+
if (seg) {
382+
seg.classList.remove('seg-animate-reverse')
383+
384+
seg.offsetHeight
385+
seg.classList.add('seg-animate')
330386
}
331387
})
332-
}
388+
389+
// 动画结束后清理内联样式,为飞回做准备
390+
openCleanupRef.current = setTimeout(() => {
391+
if (!contentEl) return
392+
contentEl.style.transition = ''
393+
contentEl.style.transform = ''
394+
contentEl.style.opacity = ''
395+
contentEl.style.overflow = ''
396+
contentEl.style.borderRadius = ''
397+
}, 620)
398+
399+
return () => {
400+
if (openCleanupRef.current) clearTimeout(openCleanupRef.current)
401+
}
402+
}, [dialogOpen, selectedBook])
333403

334404
// 执行飞回动画(Phase 2):直接操作 DOM + CSS transition
335-
const doFlyback = useCallback(() => {
405+
const doFlyback = () => {
336406
const contentEl = overlayContentRef.current
337407
const maskEl = maskRef.current
338-
const cardEl = selectedBook ? cardRefsMap.current[selectedBook.id] : null
408+
const book = selectedBookRef.current
409+
const cardEl = book ? cardRefsMap.current[book.id] : null
339410

340411
if (!contentEl || !cardEl) {
341412
// 无法计算目标,直接卸载
342413
setDialogOpen(false)
343414
closePhaseRef.current = null
344415
setSelectedBook(null)
416+
selectedBookRef.current = null
345417
setBookOpen(true)
346418
return
347419
}
@@ -354,18 +426,30 @@ export default function BookLibrary() {
354426

355427
// 先设 transition,下一帧设目标值,确保浏览器识别为动画
356428
contentEl.style.overflow = 'hidden'
357-
contentEl.style.borderRadius = '12px'
358-
contentEl.style.transition = 'transform 0.5s cubic-bezier(0.4,0,0.2,1), opacity 0.5s cubic-bezier(0.4,0,0.2,1)'
429+
contentEl.style.borderRadius = '0px'
430+
contentEl.style.transition =
431+
'transform 0.5s cubic-bezier(0.4,0,0.2,1), ' +
432+
'opacity 0.5s cubic-bezier(0.4,0,0.2,1), ' +
433+
'border-radius 0.4s cubic-bezier(0.4,0,0.2,1)'
359434

360435
if (maskEl) {
361436
maskEl.style.transition = 'opacity 0.5s cubic-bezier(0.4,0,0.2,1)'
362437
}
363438

439+
// 分段渐变逆向动画,制造飞回时的层次感
440+
const seg = contentEl.querySelector('.segmentGradient')
441+
if (seg) {
442+
seg.classList.remove('seg-animate')
443+
seg.offsetHeight
444+
seg.classList.add('seg-animate-reverse')
445+
}
446+
364447
// 下一帧应用目标值
365448
requestAnimationFrame(() => {
366449
requestAnimationFrame(() => {
367450
contentEl.style.transform = `translate(${tx}px, ${ty}px) scale(${ts})`
368451
contentEl.style.opacity = '0'
452+
contentEl.style.borderRadius = '12px'
369453
if (maskEl) {
370454
maskEl.style.opacity = '0'
371455
}
@@ -377,12 +461,13 @@ export default function BookLibrary() {
377461
setDialogOpen(false)
378462
closePhaseRef.current = null
379463
setSelectedBook(null)
464+
selectedBookRef.current = null
380465
setBookOpen(true)
381466
}, 530)
382-
}, [selectedBook])
467+
}
383468

384469
// 分段关闭入口:Phase 1 关闭书籍 → Phase 2 飞回
385-
const startClose = useCallback(() => {
470+
const startClose = () => {
386471
if (closePhaseRef.current) return
387472
closePhaseRef.current = 'book'
388473

@@ -394,10 +479,10 @@ export default function BookLibrary() {
394479
closePhaseRef.current = 'flyback'
395480
doFlyback()
396481
}, 650)
397-
}, [doFlyback])
482+
}
398483

399484
// InteractiveBook 内部关闭按钮触发(书已自行开始关闭动画)
400-
const handleBookInternalClose = useCallback(() => {
485+
const handleBookInternalClose = () => {
401486
if (closePhaseRef.current) return
402487
closePhaseRef.current = 'book'
403488

@@ -406,7 +491,7 @@ export default function BookLibrary() {
406491
closePhaseRef.current = 'flyback'
407492
doFlyback()
408493
}, 650)
409-
}, [doFlyback])
494+
}
410495

411496
return (
412497
<section className={styles.bookLibrary}>
@@ -441,38 +526,31 @@ export default function BookLibrary() {
441526

442527
{/* 书籍网格(带过滤动画) */}
443528
<div className={styles.bookGrid}>
444-
<AnimatePresence mode="popLayout">
445-
{filteredBooks.map((book) => (
446-
<motion.div
447-
key={book.id}
448-
ref={(el) => {
449-
cardRefsMap.current[book.id] = el
450-
}}
451-
layout
452-
initial={{ opacity: 0, scale: 0.8 }}
453-
animate={{ opacity: 1, scale: 1 }}
454-
exit={{ opacity: 0, scale: 0.6 }}
455-
transition={{ duration: 0.35, type: 'spring', stiffness: 300, damping: 25 }}
456-
className={styles.bookCard}
457-
onClick={() => openBook(book)}
458-
>
459-
<div className={styles.bookCoverWrap}>
460-
<img src={book.cover} alt={book.title} className={styles.bookCoverImg} loading="lazy" />
461-
<div className={styles.bookCoverOverlay}>
462-
<BookOutlined style={{ fontSize: 28, color: '#fff' }} />
463-
<span>阅读</span>
464-
</div>
465-
</div>
466-
<div className={styles.bookMeta}>
467-
<h3 className={styles.bookCardTitle}>{book.title}</h3>
468-
<p className={styles.bookCardAuthor}>{book.author}</p>
469-
<Tag color="blue" style={{ fontSize: 11 }}>
470-
{book.category}
471-
</Tag>
529+
{filteredBooks.map((book) => (
530+
<div
531+
key={book.id}
532+
ref={(el) => {
533+
cardRefsMap.current[book.id] = el
534+
}}
535+
className={styles.bookCard}
536+
onClick={() => openBook(book)}
537+
>
538+
<div className={styles.bookCoverWrap}>
539+
<img src={book.cover} alt={book.title} className={styles.bookCoverImg} loading="lazy" />
540+
<div className={styles.bookCoverOverlay}>
541+
<BookOutlined style={{ fontSize: 28, color: '#fff' }} />
542+
<span>阅读</span>
472543
</div>
473-
</motion.div>
474-
))}
475-
</AnimatePresence>
544+
</div>
545+
<div className={styles.bookMeta}>
546+
<h3 className={styles.bookCardTitle}>{book.title}</h3>
547+
<p className={styles.bookCardAuthor}>{book.author}</p>
548+
<Tag color="blue" style={{ fontSize: 11 }}>
549+
{book.category}
550+
</Tag>
551+
</div>
552+
</div>
553+
))}
476554

477555
{filteredBooks.length === 0 && (
478556
<div className={styles.bookEmpty}>
@@ -486,11 +564,24 @@ export default function BookLibrary() {
486564
{dialogOpen &&
487565
selectedBook &&
488566
createPortal(
489-
<div ref={maskRef} className={styles.bookOverlayMask} onClick={startClose}>
490-
<div ref={overlayContentRef} className={styles.bookOverlayContent} onClick={(e) => e.stopPropagation()}>
567+
<div
568+
ref={maskRef}
569+
className={styles.bookOverlayMask}
570+
onClick={OVERLAY_CONFIG.maskClosable ? startClose : undefined}
571+
>
572+
{/* 关闭按钮放在 mask 层,避免被 content 的 overflow:hidden 裁剪 */}
573+
{OVERLAY_CONFIG.showCloseButton && (
491574
<button className={styles.bookOverlayClose} onClick={startClose} aria-label="关闭">
492575
<CloseOutlined />
493576
</button>
577+
)}
578+
<div ref={overlayContentRef} className={styles.bookOverlayContent} onClick={(e) => e.stopPropagation()}>
579+
{/* 分段渐变覆层(用于飞出/飞入时的视觉效果) */}
580+
<div className={styles.segmentGradient + ' segmentGradient'}>
581+
{Array.from({ length: 6 }).map((_, i) => (
582+
<div key={i} className={`segmentStrip segmentStrip-${i + 1}`} />
583+
))}
584+
</div>
494585
<InteractiveBook
495586
coverImage={selectedBook.cover}
496587
bookTitle={selectedBook.title}

0 commit comments

Comments
 (0)