You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多
文档控制台
免费开始使用

基于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

火山引擎 最新活动