UI 动效 009:Shared Element Transition,为什么缩略图能长成详情页
2026. 7. 3. · 08:11

UI 动效 009:Shared Element Transition,为什么缩略图能长成详情页

这期拆解 Shared Element Transition / 共享元素转场:它适合卡片、缩略图、搜索框进入详情页的场景,核心是保留一个共享的视觉锚点,再用位置、尺寸、圆角和内容淡入淡出完成过渡。文中用自制 GIF、Web View Transition API、FLIP 思路和 Compose 示例说明怎么让它动起来。

点开一张卡片,如果缩略图只是淡出,详情页再淡入,用户会知道「页面变了」。但如果那张缩略图沿着同一条轨迹放大、圆角变形,最后落在详情页顶部,用户会更快明白:我刚才点的就是这里。这个动效叫 Shared Element Transition / 共享元素转场,Material Design 3 把它放在 Container transform 一类里,用来把卡片、列表项、图片、搜索框、FAB 等元素和更大的目标视图连起来。1
共享元素转场示意 GIF
共享元素转场示意 GIF
自制机制示意:列表卡片的图片与容器被插值到详情页位置,非真实产品截图。

它解决的不是「好看」,而是位置感

共享元素转场的核心是让两个界面之间保留一个「不变的锚」。Material 3 对 Container transform 的描述很直接:它用于把一个元素无缝变成更详细的视图,例如 Card 展开成详情页;持久元素通常是容器,也可以是关键图片。1
它适合这几类交互:
  • 列表卡片进入详情页:电商商品、相册缩略图、文章卡片。
  • 搜索框展开成完整搜索页:小输入框变成承载结果的页面。
  • FAB 展开成表单或动作面板:圆形按钮变成一个有内容的面板。
  • 图片预览进入全屏:小图放大,用户不用重新找那张图。
不适合的场景也很明确:两个界面没有同一个对象,只是从「首页」切到「设置页」这类弱相关页面时,硬做共享元素会让关系变得假。Material 3 在 Top level 转场里反而建议快速淡出再淡入,因为顶层目的地之间不一定有关联,不需要用持久元素强行拉关系。2

为什么它像「同一个元素」在移动

真正动起来的通常不是原始 DOM 或原始控件本身,而是一段过渡期里的视觉替身:先记住起点的矩形,再记住终点的矩形,然后在这两个矩形之间插值。Web 的 View Transition API 也采用类似思路:浏览器会截取旧状态和新状态的快照,DOM 更新时先抑制渲染,再用 CSS Animation 播放过渡。3
这就是它看起来顺的原因。用户看到的不是「卡片消失,详情页出现」,而是同一个轮廓从 A 点变到 B 点。中间可以同时发生几件事:
  1. 容器的位置、宽高、圆角在变化。
  2. 共享图片保持连续,只调整尺寸和裁切。
  3. 旧内容淡出,新内容淡入,避免两套文字挤在一起。
  4. 背后的页面轻微淡出或缩放,让层级关系更清楚。
Android Compose 把这件事拆成 sharedElement()sharedBounds() 两类:前者适合同一内容在两个界面之间移动,例如同一张图片;后者适合内容不同但边界相同的容器,例如列表行变成详情页容器。4

Web 里怎么做:用 View Transition 或手写 FLIP

现代浏览器已经有 View Transition API。MDN 的定义是:它可以在同一页面应用的 DOM 状态之间,也可以在多页面应用的文档导航之间创建视图过渡。document.startViewTransition() 用来启动同文档转场,view-transition-name 用来指定哪些元素要独立参与快照和动画。5
一个极简版本可以这样写:
.card-thumb,
.detail-hero {
  view-transition-name: hero-image;
}

::view-transition-group(hero-image) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(.2, 0, 0, 1);
}
function openDetail(id) {
  const update = () => renderDetailPage(id);

if (!document.startViewTransition) {
    update();
    return;
  }

document.startViewTransition(update);
}
这里最容易漏掉的是唯一性:同一时刻参加转场的共享元素,名称要能明确匹配到一组旧快照和新快照。页面里同时有一堆卡片时,不要让所有缩略图都叫 hero-image,应该把商品 ID、文章 ID 或列表索引放进名称里。
如果目标环境不支持 View Transition API,或者你要在更底层控制动画,传统办法是 FLIP。Paul Lewis 把 FLIP 拆成 First、Last、Invert、Play:先量起点,再量终点,用 transform 反向抵消位移,最后把反向变换清掉,让元素播放到终点。6
伪代码大概是这样:
const first = card.getBoundingClientRect();
renderDetailLayout();
const last = hero.getBoundingClientRect();

const dx = first.left - last.left;
const dy = first.top - last.top;
const sx = first.width / last.width;
const sy = first.height / last.height;

hero.animate([
  { transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` },
  { transform: 'translate(0, 0) scale(1, 1)' }
], {
  duration: 320,
  easing: 'cubic-bezier(.2, 0, 0, 1)'
});
这段代码有一个取舍:scale(sx, sy) 会把图片或文字一起拉伸。真实项目里通常会把容器、图片、文字拆开动。容器负责尺寸和圆角,图片负责裁切,文字只做淡入淡出,避免中间帧变形得像橡皮。

Android / Compose 里的关键点

Compose 的共享元素转场需要把相关 Composable 放在 SharedTransitionLayout 提供的作用域里,再给两端元素挂上相同的共享状态;官方示例用 rememberSharedContentState(key = "image") 把列表页图片和详情页图片匹配起来。4
SharedTransitionLayout {
  AnimatedContent(showDetails, label = "detail") { target ->
    if (!target) {
      ThumbCard(
        modifier = Modifier.sharedElement(
          rememberSharedContentState(key = "photo-$id"),
          animatedVisibilityScope = this@AnimatedContent
        )
      )
    } else {
      DetailHero(
        modifier = Modifier.sharedElement(
          rememberSharedContentState(key = "photo-$id"),
          animatedVisibilityScope = this@AnimatedContent
        )
      )
    }
  }
}
如果转场对象不是「同一张图」,而是「同一个容器区域」,Compose 文档更推荐 sharedBounds()。文档也提醒 modifier 顺序会影响测量边界:尺寸、padding、border 放在共享元素修饰符前后不同,动画就可能出现跳动。4

设计时别把它做满全场

共享元素转场的强项是建立对象连续性,所以一屏里只挑最能帮用户定位的元素。商品详情页通常挑商品图,文章详情页挑封面或标题卡片,搜索页挑搜索框。所有元素一起飞,会让用户不知道该看哪里。
还要处理两件小事:返回动画和减少动画。返回时,路径最好能反过来走回原来的卡片;如果列表已刷新或卡片不在屏幕里,就降级成淡出或普通返回。对开启减少动态效果的用户,保留淡入淡出即可,不必播放大幅位移。
判断它有没有必要,可以问一句:如果把这个动效删掉,用户会不会更难确认「我从哪里来、到了哪里去」?答案是会,就值得做。答案是否,淡入淡出可能更干净。

이 채널의 다른 콘텐츠

관련 콘텐츠

  • 로그인하면 댓글을 작성할 수 있습니다.