返回

SwiftUI 水平滚动视图对齐问题:偶数可见元素吸附修复

IOS

SwiftUI 水平滚动视图:初始加载时,可见元素为偶数时的对齐问题

水平ScrollView通常用于展示一组可以横向滑动的元素。结合scrollTargetBehavior(.viewAligned),可以实现元素滑动到视图中心并对齐的效果,即所谓的“吸附”效果。这种交互很直观。不过,一个常见的问题出现在初始加载时,当可见元素数量为偶数时,初始项无法正确对齐到视图中心。这可能导致视觉上的不协调,与预期的吸附行为不一致。本篇文章旨在探讨这个问题并提供解决方案。

问题分析

ScrollView 加载时,SwiftUI 会根据初始位置和设置计算内容偏移。如果 visibleItems 是偶数,其中心位置可能并不正好落在某个单独的视图上,而是位于两个视图之间。此时,在初始渲染时,scrollTargetBehavior(.viewAligned) 可能会无法准确定位目标,导致偏移。而当用户滑动视图时,SwiftUI的滚动逻辑会纠正这一偏差,确保后续的元素能够正确吸附。

解决方案

这里提供两种可行的方案:

方案一:调整初始位置计算

与其直接使用传入的initialPosition,可以稍微修改这个值。将初始位置调整为在可见元素列表中心左边的索引,这样确保初始时滑动对齐。例如当有6个可见项,要显示列表中的第10项(假设为initialPosition), 实际我们要让第8项成为第一个显示的元素。

  1. 计算初始项索引偏移量 : 用(visibleItems / 2) - 1 算出要从initialPosition 减去的索引值。 例如 visibleItems = 8 时, 这个值 = (8 / 2) - 1 = 3 。 如果要使第 5 项出现在中间, 实际初始化位置应该为: 5 - 3 = 2, 所以应该显示列表的第二项。
  2. 更新onAppear 方法 : 将 scrollViewProxy.scrollTo 方法修改为使用计算出的初始位置。
  3. 实现 resetCurrentIndex() 函数 : 用来同步 currentIndex 的值到正确的初始值. 确保滚动到初始位置后 currentIndex 的值是准确的,之后的用户滚动时能够被正确识别。
struct CircleScrollView: View {

    @State(initialValue: 2)
    var initialPosition: Int

    @State(initialValue: 8)
    private var visibleItems: Int

    @State(initialValue: 0)
    private var currentIndex: Int

    private let spacing: CGFloat = 16

    var body: some View {
        ZStack(alignment: .leading) {
             Rectangle()
                .fill(Color.gray.opacity(0.2))
                .ignoresSafeArea()
                .frame(maxWidth: UIScreen.main.bounds.width / 2, maxHeight: .infinity, alignment: .leading)

            GeometryReader { geometry in
                let totalSpacing = spacing * CGFloat(visibleItems - 1)
                let circleSize = (geometry.size.width - totalSpacing) / CGFloat(visibleItems)

                ScrollViewReader { scrollViewProxy in
                    ScrollView(.horizontal) {
                        HStack(spacing: spacing) {
                            ForEach(1..<100) { index in
                                ZStack {
                                    Text("\(index)")
                                    Circle().fill(Color(.tertiarySystemFill))
                                }
                                .frame(width: circleSize, height: circleSize)
                                .id(index)
                            }
                        }
                        .scrollTargetLayout()
                        .padding(.horizontal, (geometry.size.width - circleSize) / 2)
                        .onAppear {
                            let initialOffset = (visibleItems / 2) - 1
                            let calculatedPosition = initialPosition - initialOffset
                            scrollViewProxy.scrollTo(calculatedPosition, anchor: .center)
                            resetCurrentIndex(calculatedPosition)
                            
                        }
                    }
                    .scrollIndicators(.never)
                    .scrollTargetBehavior(.viewAligned)
                 
                }
            }
        }
    }
    private func resetCurrentIndex(_ initialPosition: Int){
       
       self.currentIndex = initialPosition
        
    }
}

方案二:强制进行一次布局刷新

另一种方式是通过在视图初次出现后强制进行一次布局更新来实现对齐。这个方案通常可以强制ScrollView重新计算其位置和内容偏移。通过在onAppear时添加一个很小的延迟和 scrollProxy来完成。这个方法相对直接,通过延迟重新定位视图可能足以解决初始化对齐问题。

  1. 使用.onAppearDispatchQueue.main.asyncAfter添加一个轻微的延迟

     .onAppear {
       DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
           scrollViewProxy.scrollTo(initialPosition, anchor: .center)
            currentIndex = initialPosition
       }
     }
    
    
  2. DispatchQueue.main.asyncAfter将重新布局操作调度到主线程上的延迟执行,确保布局先加载完成。

  3. 这种方式的延迟很小,对用户视觉影响很小。scrollTo 函数将确保指定元素对齐到视图中心,同时也需要将当前currentIndex重置,保证之后能正确的操作当前currentIndex

struct CircleScrollView: View {

    @State(initialValue: 2)
    var initialPosition: Int

    @State(initialValue: 8)
    private var visibleItems: Int

    @State(initialValue: 0)
    private var currentIndex: Int

    private let spacing: CGFloat = 16
    
    var body: some View {
        ZStack(alignment: .leading) {
            
            // For visuals of screen centre
             Rectangle()
                .fill(Color.gray.opacity(0.2))
                .ignoresSafeArea()
                .frame(maxWidth: UIScreen.main.bounds.width / 2, maxHeight: .infinity, alignment: .leading)
          
            GeometryReader { geometry in

                let totalSpacing = spacing * CGFloat(visibleItems - 1)
                let circleSize = (geometry.size.width - totalSpacing) / CGFloat(visibleItems)
               
                ScrollViewReader { scrollViewProxy in
                    ScrollView(.horizontal) {
                        HStack(spacing: spacing) {
                            ForEach(1..<100) { index in
                                ZStack {
                                    Text("\(index)")
                                    Circle().fill(Color(.tertiarySystemFill))
                                }
                                .frame(width: circleSize, height: circleSize)
                                .id(index)
                            }
                        }
                        .scrollTargetLayout()
                        .padding(.horizontal, (geometry.size.width - circleSize) / 2)
                        .onAppear {
                          DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                            scrollViewProxy.scrollTo(initialPosition, anchor: .center)
                            currentIndex = initialPosition
                                      }
                         }
                    }
                    .scrollIndicators(.never)
                   .scrollTargetBehavior(.viewAligned)
                   
                }
            }
        }
    }
}

选择方案

方案一更直接地控制了初始位置,通常是更稳健的解决方案。它显式调整起始位置,能确保中心位置落在视图的正中心。而方案二利用布局刷新,方法更简洁。建议优先尝试方案一,如果情况复杂可以尝试方案二进行修复。两种方法在实践中都有使用价值,可以根据具体的应用场景选择。

安全建议

  • 测试不同 visibleItems 组合 : 在各种设备和不同的 visibleItems 参数组合下彻底测试解决方案,确保它在不同情景下都能正常工作。
  • 考虑动态内容 : 如果内容是动态变化的,确保初始对齐方案不会受到内容加载时间或其他因素的影响。
  • 避免过度依赖延迟 : 不要依赖很长的延迟来解决问题。延迟过长可能会引入不确定性。通常小的延迟或手动指定起始索引都可以快速的修复这个问题。

在解决 SwiftUI 的水平滚动视图吸附问题时,深入理解其工作原理是必要的。尝试使用文中讨论的方法调整初始偏移或触发强制布局刷新,你通常能成功地修复这一问题。通过这样的探索,我们不仅解决了具体问题,还能更深入了解 SwiftUI 的布局机制。