创建 Elastic Header 动画效果(Apple TV)

了解如何创建类似 Apple TV 应用中的视差头部效果(Parallax Header)/弹性头部效果(Elastic Header)。

创建 Elastic Header 动画效果(Apple TV)

在 Apple TV 移动应用中,随着用户滑动,顶部图片会以不同速度移动产生深度感和拉伸感。

0:00
/0:04

苹果 TV 应用中的设计效果

这种效果在 UI 设计中,通常被称为"视差头部效果"(Parallax Header Effect)或"弹性头部效果"(Elastic Header Effect)。

  • 图片顶部始终固定在屏幕顶端
  • 随着手指往下滑动,图片在纵轴和横轴上均会被轻微放大。

GeometryReader + Frame

创建 Elastic Header Effect 的常见方式是使用 GeometryReader 结合 .frame 修饰器:

  • 通过 GeometryReader 获取用户滑动时的偏移量。
  • 通过偏移量,动态计算 .frame() 的高度和宽度,从而实现图片放大效果。
  • 还可以通过偏移量,动态计算 .offset,实现图片拉伸效果。

示例代码结构是这样的:

var body: some View {
    GeometryReader { proxy in
        let offset = offset(for: proxy)
        let heightModifier = heightModifier(for: proxy)
        let blurRadius = min(
            heightModifier / 20,
            max(10, heightModifier / 20)
        )
        content()
            .edgesIgnoringSafeArea(.horizontal)
            .frame(
                width: proxy.size.width,
                height: proxy.size.height + heightModifier
            )
            .offset(y: offset)
            .blur(radius: blurRadius)
    }.frame(height: defaultHeight)
}

在 iOS18 上(WWDC24),苹果为 ScrollView 添加了众多功能。

通过结合使用 ScrollGeometry.onScrollPhaseChange() 修饰器,现在可以更加简单的实现 Elastic Header Effect 效果。

ScrollGeometry | Apple Developer Documentation
A type that defines the geometry of a scroll view.
onScrollPhaseChange(_:) | Apple Developer Documentation
Adds an action to perform when the scroll phase of the first scroll view in the hierarchy changes.

ScrollGeometry + onScrollPhaseChange

创建基本布局

创建基本的布局效果,有几个关键点:

  • 使用 ScrollView() 添加滑动功能,使用 .scrollIndicators(.hidden) 隐藏滚动条。
  • 使用 ZStack 创建布局,封面图片作为下层,其他文字部分作为上层展示。不使用.overlay()是为了添加上滑时滚动视差效果。
  • 使用  .ignoresSafeArea(.container, edges: .top),让图片对齐屏幕顶部区域,忽略安全区。

基础布局代码结构如下:

ScrollView {
    VStack(alignment: .center, spacing: 16) {
        ZStack(alignment: .bottomLeading) {
            // 封面图片
            Image(bookmark.coverImage)
                .padding(.horizontal, -16)
            
            MovieInfoView(bookmark: bookmark)
        }
    }
    .frame(maxWidth: .infinity)
    .padding(.horizontal, 16)
}
.ignoresSafeArea(.container, edges: .top)
.scrollIndicators(.hidden)

这将能够创建类似下面这样的效果:

0:00
/0:04

添加 ScrollGeometry 变量

首先,我们需要创建一个 ScrollGeometry 对象,通过它来获取滚动数据(内容偏移量、滚动边界、滚动内容大小等),这是所有后续添加动画效果的基础。

// Scroll Animation Properties
@State private var scrollProperties: ScrollGeometry = .init(
    contentOffset: .zero,
    contentSize: .zero,
    contentInsets: .init(),
    containerSize: .zero
)

创建一个 scrollProperties 变量,它是一个 ScrollGeometry 对象。

通过 .onScrollGeometryChange 修饰器,绑定 scrollProperties ,从而在滑动时实时更新 scrollProperties 的值。

ScrollView {
    VStack(alignment: .center, spacing: 16) {
        ZStack(alignment: .bottomLeading) {
            // 封面图片
            Image(bookmark.coverImage)
                .padding(.horizontal, -16)
            
            MovieInfoView(bookmark: bookmark)
        }
    }
    .frame(maxWidth: .infinity)
    .padding(.horizontal, 16)
}
.ignoresSafeArea(.container, edges: .top)
.scrollIndicators(.hidden)
.onScrollGeometryChange(
    for: ScrollGeometry.self,
    of: {
        $0
    },
    action: { oldValue, newValue in
        scrollProperties = newValue
    }
)

添加下拉时,图片轻微放大效果

在 Apple TV 应用中,随着手指下滑,封面图片始终保持在屏幕顶部并有拉伸效果。一般的做法是通过动态计算 .frame() 从而实现放大效果。但这种方式对布局有影响,通过 .scaleEffect() 实现是更好的选择

在图片上添加 .scaleEffect() 修饰器:

ScrollView {
    VStack(alignment: .center, spacing: 16) {
        ZStack(alignment: .bottomLeading) {
            // 封面图片
            Image(bookmark.coverImage)
                .padding(.horizontal, -16)
            
            MovieInfoView(bookmark: bookmark)
        }
    }
    .frame(maxWidth: .infinity)
    .padding(.horizontal, 16)
}
.ignoresSafeArea(.container, edges: .top)
.scrollIndicators(.hidden)
.onScrollGeometryChange(
    for: ScrollGeometry.self,
    of: {
        $0
    },
    action: { oldValue, newValue in
        scrollProperties = newValue
    }
)
.scaleEffect(
    scrollProperties.offsetY < 0
        ? 1.0 + abs(scrollProperties.offsetY) / 500 : 1.0,
    anchor: .bottom
)
0:00
/0:03
注意 .anchor 需要设置为 .bottom,才能保证图片顶部固定。

添加上滑时,图片移动视差效果

在 Apple TV 移动应用中,当上滑时,背景图片会以不同的速度移动 —— 从而创建视差效果。

0:00
/0:04

通过动态计算 .offset 修饰器,实现类似效果:

ScrollView {
    VStack(alignment: .center, spacing: 16) {
        ZStack(alignment: .bottomLeading) {
            // 封面图片
            Image(bookmark.coverImage)
                .padding(.horizontal, -16)
            
            MovieInfoView(bookmark: bookmark)
        }
    }
    .frame(maxWidth: .infinity)
    .padding(.horizontal, 16)
}
.ignoresSafeArea(.container, edges: .top)
.scrollIndicators(.hidden)
.onScrollGeometryChange(
    for: ScrollGeometry.self,
    of: {
        $0
    },
    action: { oldValue, newValue in
        scrollProperties = newValue
    }
)
.offset(
    y: scrollProperties.offsetY > 0
        ? scrollProperties.offsetY * 0.5
        : 0
)
0:00
/0:03

下滑时,offset 不做任何调整。上滑时,offset 才生效。

添加上滑时,图片渐隐效果(透明+模糊)

通过 .opacity() 修饰器实现:

0:00
/0:01

opacity 效果不够理想。

通过结合 .blur 实现:

ScrollView {
    VStack(alignment: .center, spacing: 16) {
        ZStack(alignment: .bottomLeading) {
            // 封面图片
            Image(bookmark.coverImage)
                .padding(.horizontal, -16)
            
            MovieInfoView(bookmark: bookmark)
        }
    }
    .frame(maxWidth: .infinity)
    .padding(.horizontal, 16)
}
.ignoresSafeArea(.container, edges: .top)
.scrollIndicators(.hidden)
.onScrollGeometryChange(
    for: ScrollGeometry.self,
    of: {
        $0
    },
    action: { oldValue, newValue in
        scrollProperties = newValue
    }
)
.offset(
    y: scrollProperties.offsetY > 0
        ? scrollProperties.offsetY * 0.5
        : 0
)
.blur(
    radius: scrollProperties.offsetY > 0
        ? min(scrollProperties.offsetY / 80, 20) : 0
)
.opacity(
    scrollProperties.offsetY > 0
        ? max(1.0 - (scrollProperties.offsetY / 300), 0.8) : 1.0
)
0:00
/0:03

添加模糊效果

兼容 iOS 17 方案