SwiftUI中如何实现Header的滚动折叠与淡入淡出效果
SwiftUI 滚动Header平滑缩放与淡入淡出优化实现
我需要实现滚动隐藏Header的功能:向下滚动时Header缩小高度并淡出,向上滚动时恢复初始高度并淡入。尝试给透明度添加动画但效果不流畅,以下是基于滚动方向实现的初始代码:
struct ScrollToHideView: View { @State private var naturalScrollOffset: CGFloat = 0 @State private var lastNaturalOffset: CGFloat = 0 @State private var headerOffset: CGFloat = 0 @State private var isScrollingUp = false @State private var opacity = 1.0 @State private var tempH = 0.0 @State private var top = 0.0 var body: some View { GeometryReader { let safeArea = $0.safeAreaInsets let headerHeight = 60.0 + safeArea.top ScrollView(.vertical) { LazyVStack { ForEach(0..<10, id: \.self) { index in DummyView() } } .padding(16) } .overlay(content: { Text("\(naturalScrollOffset)") }) .safeAreaInset(edge: .top, spacing: 0, content: { HeaderView() .padding(.bottom, 16) .frame(height: headerHeight, alignment: .bottom) .background(.blue) .opacity(opacity) .offset(y: -headerOffset) }) .onScrollGeometryChange(for: CGFloat.self) { proxy in let maxHeight = proxy.contentSize.height - proxy.containerSize.height return max(min(proxy.contentOffset.y + headerHeight, maxHeight),0) } action: { oldValue, newValue in let isScrollingUp = oldValue < newValue headerOffset = min(max(newValue - lastNaturalOffset, 0), headerHeight) self.isScrollingUp = isScrollingUp // animating opacity withAnimation(.easeIn(duration: 2)) { if self.isScrollingUp { opacity = 0 } else { opacity = 1 } } naturalScrollOffset = newValue } .onScrollPhaseChange({ oldPhase, newPhase, context in if !newPhase.isScrolling && (headerOffset != 0 || headerOffset != headerHeight) { withAnimation(.snappy(duration: 0.25, extraBounce: 0)) { if headerOffset > (headerHeight * 0.5) && naturalScrollOffset > headerHeight { headerOffset = headerHeight } else { headerOffset = 0 } lastNaturalOffset = naturalScrollOffset - headerOffset } } }) .onChange(of: isScrollingUp) { oldValue, newValue in lastNaturalOffset = naturalScrollOffset - headerOffset } .ignoresSafeArea(.container, edges: .top) } } } extension ScrollToHideView { @ViewBuilder func HeaderView() -> some View { HStack(spacing: 20) { Image("user1") .resizable() .aspectRatio(contentMode: .fit) .frame(height: 25) Spacer(minLength: 0) Button("", systemImage: "airplayvideo") { } Button("", systemImage: "bell") { } Button("", systemImage: "magnifyingglass") { } } .font(.title2) .foregroundStyle(.primary) .padding(.horizontal, 16) } @ViewBuilder func DummyView() -> some View { VStack(alignment: .leading, spacing: 6) { RoundedRectangle(cornerRadius: 6) .frame(height: 220) HStack(spacing: 10) { Circle() .frame(width: 45, height: 45) VStack(alignment: .leading, spacing: 4) { Rectangle() .frame(height: 10) HStack { Rectangle() .frame(width: 100) Rectangle() .frame(width: 80) Rectangle() .frame(width: 80) } .frame(height: 10.0) } } } .foregroundStyle(.tertiary) } }
问题分析
原代码的核心问题:
- 透明度基于滚动方向直接切换0/1,无渐变过渡,动画突兀
onScrollGeometryChange中频繁触发独立withAnimation,导致动画队列混乱,出现卡顿- 存在未使用的冗余状态变量(
tempH、top)
优化方案
核心调整点:
- 将透明度与Header偏移量绑定,随滚动进度线性渐变
- 用隐式动画统一控制所有属性变化,避免动画冲突
- 简化冗余逻辑,清理无用状态变量
优化后完整代码
struct ScrollToHideView: View { @State private var naturalScrollOffset: CGFloat = 0 @State private var lastNaturalOffset: CGFloat = 0 @State private var headerOffset: CGFloat = 0 @State private var isScrollingUp = false var body: some View { GeometryReader { proxy in let safeArea = proxy.safeAreaInsets let headerHeight = 60.0 + safeArea.top // 动态计算透明度:随Header隐藏进度线性变化 let opacity = 1 - (headerOffset / headerHeight) ScrollView(.vertical) { LazyVStack { ForEach(0..<10, id: \.self) { index in DummyView() } } .padding(16) } .overlay(content: { Text("\(naturalScrollOffset)") }) .safeAreaInset(edge: .top, spacing: 0, content: { HeaderView() .padding(.bottom, 16) .frame(height: headerHeight, alignment: .bottom) .background(.blue) .opacity(opacity) .offset(y: -headerOffset) // 隐式动画:让offset和opacity变化同步平滑过渡 .animation(.easeInOut(duration: 0.2), value: headerOffset) }) .onScrollGeometryChange(for: CGFloat.self) { scrollProxy in let maxHeight = scrollProxy.contentSize.height - scrollProxy.containerSize.height return max(min(scrollProxy.contentOffset.y + headerHeight, maxHeight), 0) } action: { oldValue, newValue in isScrollingUp = oldValue < newValue headerOffset = min(max(newValue - lastNaturalOffset, 0), headerHeight) naturalScrollOffset = newValue } .onScrollPhaseChange { oldPhase, newPhase, context in guard !newPhase.isScrolling else { return } let shouldCollapse = headerOffset > (headerHeight * 0.5) && naturalScrollOffset > headerHeight withAnimation(.snappy(duration: 0.25, extraBounce: 0)) { headerOffset = shouldCollapse ? headerHeight : 0 lastNaturalOffset = naturalScrollOffset - headerOffset } } .onChange(of: isScrollingUp) { _, _ in lastNaturalOffset = naturalScrollOffset - headerOffset } .ignoresSafeArea(.container, edges: .top) } } } extension ScrollToHideView { @ViewBuilder func HeaderView() -> some View { HStack(spacing: 20) { Image("user1") .resizable() .aspectRatio(contentMode: .fit) .frame(height: 25) Spacer(minLength: 0) Button("", systemImage: "airplayvideo") {} Button("", systemImage: "bell") {} Button("", systemImage: "magnifyingglass") {} } .font(.title2) .foregroundStyle(.primary) .padding(.horizontal, 16) } @ViewBuilder func DummyView() -> some View { VStack(alignment: .leading, spacing: 6) { RoundedRectangle(cornerRadius: 6) .frame(height: 220) HStack(spacing: 10) { Circle() .frame(width: 45, height: 45) VStack(alignment: .leading, spacing: 4) { Rectangle() .frame(height: 10) HStack { Rectangle().frame(width: 100) Rectangle().frame(width: 80) Rectangle().frame(width: 80) } .frame(height: 10.0) } } } .foregroundStyle(.tertiary) } }
优化细节说明
- 动态透明度计算:
opacity = 1 - (headerOffset / headerHeight)让透明度随Header的隐藏进度线性变化,完全隐藏时透明度为0,完全显示时为1,过渡自然。 - 统一隐式动画:在HeaderView上添加
.animation(.easeInOut(duration: 0.2), value: headerOffset),让offset和opacity的变化共享同一个动画参数,避免独立动画导致的冲突和卡顿。 - 简化逻辑:移除未使用的状态变量,清理
onScrollPhaseChange的条件判断,让代码更简洁易维护。 - 动画参数优化:使用
easeInOut曲线让过渡更顺滑,0.2秒的时长平衡了流畅度和响应速度。
内容的提问来源于stack exchange,提问作者Jobie J
相关产品推荐
相关产品推荐

