You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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)
    }
}

优化细节说明

  1. 动态透明度计算:opacity = 1 - (headerOffset / headerHeight)让透明度随Header的隐藏进度线性变化,完全隐藏时透明度为0,完全显示时为1,过渡自然。
  2. 统一隐式动画:在HeaderView上添加.animation(.easeInOut(duration: 0.2), value: headerOffset),让offset和opacity的变化共享同一个动画参数,避免独立动画导致的冲突和卡顿。
  3. 简化逻辑:移除未使用的状态变量,清理onScrollPhaseChange的条件判断,让代码更简洁易维护。
  4. 动画参数优化:使用easeInOut曲线让过渡更顺滑,0.2秒的时长平衡了流畅度和响应速度。

内容的提问来源于stack exchange,提问作者Jobie J

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 22:34:55