创建 Elastic Header 动画效果(Apple TV)
了解如何创建类似 Apple TV 应用中的视差头部效果(Parallax Header)/弹性头部效果(Elastic Header)。
在 Apple TV 移动应用中,随着用户滑动,顶部图片会以不同速度移动产生深度感和拉伸感。
苹果 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 + 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)这将能够创建类似下面这样的效果:
添加 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
)注意.anchor需要设置为.bottom,才能保证图片顶部固定。
添加上滑时,图片移动视差效果
在 Apple TV 移动应用中,当上滑时,背景图片会以不同的速度移动 —— 从而创建视差效果。
通过动态计算 .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
)下滑时,offset 不做任何调整。上滑时,offset 才生效。
添加上滑时,图片渐隐效果(透明+模糊)
通过 .opacity() 修饰器实现:
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
)
Comments ()