基于ScrollView滚动位置动态更新zIndex的实现方案
封面流横向ScrollView右侧图片重叠问题
我实现了一个封面流样式的横向ScrollView,图片来源于数组,设置间距为-100以让非屏幕中间的图片显示在当前图片后方。目前左侧效果正常,但当前图片右侧的其他图片出现交叉重叠问题(中间纸飞机图标被右侧下载图标遮挡):

我希望通过zIndex在用户滚动画廊时动态更新视图,曾尝试使用GeometryReader结合@State变量selectedItemInd,但视图未更新。我认为每次滚动时不仅需要知道中间位置的项,还需获取所有项的位置。以下是我的代码:
import SwiftUI import MusicKit struct ContentView: View { #if false let images = ["Ghost - Single", "Burre - Single", "Butterfly 3001", "Tension (Deluxe)", "Shreya Ghoshal_ Best of Me", "Life - EP", "Liebeskummer lohnt sich nicht"] #else let images = ["externaldrive.fill.badge.checkmark", "square.and.arrow.up.fill", "square.and.arrow.up.circle", "folder.circle", "paperplane.fill", "square.and.arrow.down", "square.and.arrow.down.fill", "doc.badge.gearshape", "square.and.arrow.up.on.square.fill"] let colours = [Color.red, Color.blue, Color.green, Color.yellow, Color.orange, Color.purple, Color.pink, Color.cyan, Color.indigo] #endif var itemWidthHeight: CGFloat = CGFloat(200) @StateObject var albums = MusicStorage.shared @StateObject var authorizationObject = AuthorizationHandler.shared // @State var selectedItemInd: Int = 0 var body: some View { VStack{ GeometryReader { geo in ScrollView(.horizontal, showsIndicators: false){ HStack(spacing: -100) { ForEach(0..<images.count) { s in Image(systemName: images[s]) .resizable() .aspectRatio(contentMode: .fit) .frame(width: itemWidthHeight, height: itemWidthHeight) .background(colours[s]) .visualEffect { effect, proxy in let frame = proxy.frame(in: .scrollView(axis: .horizontal)) let distance = frame.minX // print(distance) return effect //only rotate at left or right swipe, not both at once // distance to left is negative, distance to right is positive value .rotation3DEffect(Angle(degrees: distance < 0 ? min(-distance * 0.3, 40) : 0), axis: (x: 0.0, y: 1.0, z: 0.0)) .rotation3DEffect(Angle(degrees: distance > 0 ? max(-distance * 0.3, -40) : 0), axis: (x: 0.0, y: 1.0, z: 0.0)) } // .frame(width: itemWidthHeight, height: itemWidthHeight, alignment: .center) } } .scrollTargetLayout() } .frame(width: geo.size.width, height: geo.size.height, alignment: .center) .contentMargins((geo.size.width / 2) - itemWidthHeight / 2) // scroll view item should be in middle position .scrollTargetBehavior(.viewAligned(limitBehavior: .automatic)) } } .onAppear() { albums.loadAlbums() } // Sheet is presented if authorization failed (init) .sheet(isPresented: $authorizationObject.isSheetPresented) { AuthorizationSheet(musicAuthorizationStatus: $authorizationObject.isAuthorizedForMusicKit) .interactiveDismissDisabled() } } } #Preview { ContentView() }
或许我的思路有误,是否有更简单的解决方案?恳请指教。
解决方案
核心问题
重叠的根源是HStack的渲染顺序:从左到右依次渲染,右侧视图默认zIndex更高,所以会覆盖左侧(包括中间)的视图。要实现封面流的层级逻辑,需要让距离屏幕中心越近的视图zIndex越高。
方案一:绑定滚动位置,阶梯式设置zIndex
通过scrollPosition跟踪当前居中的item索引,然后给每个item计算对应的zIndex:中心item层级最高,左右两侧item随距离递增层级递减。
修改后的代码:
import SwiftUI import MusicKit struct ContentView: View { #if false let images = ["Ghost - Single", "Burre - Single", "Butterfly 3001", "Tension (Deluxe)", "Shreya Ghoshal_ Best of Me", "Life - EP", "Liebeskummer lohnt sich nicht"] #else let images = ["externaldrive.fill.badge.checkmark", "square.and.arrow.up.fill", "square.and.arrow.up.circle", "folder.circle", "paperplane.fill", "square.and.arrow.down", "square.and.arrow.down.fill", "doc.badge.gearshape", "square.and.arrow.up.on.square.fill"] let colours = [Color.red, Color.blue, Color.green, Color.yellow, Color.orange, Color.purple, Color.pink, Color.cyan, Color.indigo] #endif var itemWidthHeight: CGFloat = 200 @StateObject var albums = MusicStorage.shared @StateObject var authorizationObject = AuthorizationHandler.shared @State private var scrollPosition: Int? = 0 // 跟踪当前居中的item索引 var body: some View { VStack{ GeometryReader { geo in ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false){ HStack(spacing: -100) { ForEach(0..<images.count) { s in Image(systemName: images[s]) .resizable() .aspectRatio(contentMode: .fit) .frame(width: itemWidthHeight, height: itemWidthHeight) .background(colours[s]) .visualEffect { effect, proxy in let frame = proxy.frame(in: .scrollView(axis: .horizontal)) let distance = frame.minX return effect .rotation3DEffect(Angle(degrees: distance < 0 ? min(-distance * 0.3, 40) : 0), axis: (0.0, 1.0, 0.0)) .rotation3DEffect(Angle(degrees: distance > 0 ? max(-distance * 0.3, -40) : 0), axis: (0.0, 1.0, 0.0)) } .zIndex(calculateZIndex(for: s, centerIndex: scrollPosition ?? 0)) .id(s) // 绑定id用于滚动位置跟踪 } } .scrollTargetLayout() } .frame(width: geo.size.width, height: geo.size.height, alignment: .center) .contentMargins((geo.size.width / 2) - itemWidthHeight / 2) .scrollTargetBehavior(.viewAligned(limitBehavior: .automatic)) .scrollPosition(id: $scrollPosition) // 绑定滚动位置 } } } .onAppear() { albums.loadAlbums() } .sheet(isPresented: $authorizationObject.isSheetPresented) { AuthorizationSheet(musicAuthorizationStatus: $authorizationObject.isAuthorizedForMusicKit) .interactiveDismissDisabled() } } // 计算zIndex:中心item层级最高,左右依次递减 private func calculateZIndex(for index: Int, centerIndex: Int) -> Double { let distance = abs(index - centerIndex) return Double(images.count - distance) } } #Preview { ContentView() }
方案二:实时计算位置,平滑过渡zIndex
如果想要滚动时zIndex平滑变化,不需要绑定滚动位置,直接在visualEffect里计算每个item与屏幕中心的距离,动态设置zIndex:
// 替换原visualEffect部分 .visualEffect { effect, proxy in let frame = proxy.frame(in: .global) let screenCenterX = UIScreen.main.bounds.midX let distance = abs(frame.midX - screenCenterX) // 距离越近zIndex越高,50是调整平滑度的系数 let zIndex = Double(max(0, images.count - distance / 50)) return effect .rotation3DEffect(Angle(degrees: frame.minX < 0 ? min(-frame.minX * 0.3, 40) : 0), axis: (0.0, 1.0, 0.0)) .rotation3DEffect(Angle(degrees: frame.minX > 0 ? max(-frame.minX * 0.3, -40) : 0), axis: (0.0, 1.0, 0.0)) .zIndex(zIndex) }
这种方式不需要额外跟踪滚动位置,层级过渡更自然,适合追求平滑动画的场景。
内容的提问来源于stack exchange,提问作者Christian




