swiftswiftui

Center View align SwiftUI ScrollView


I have a basic horizontal scroll view where each image has the same height, but their widths can be different. Currently it seems like the view aligned modifier causes the images to align to the leading edge when the view is scrolled. If for example I click on an image (not current aligned view) from the edge of the screen then the scrollview will scroll to it and center align it, probably because of:

.scrollPosition(id: $detailScrollPosition, anchor: .center)

So I can center align the images by clicking them, how can I have it also center align for scrolling?

import SwiftUI
import Kingfisher

struct TopPostMediaView: View {
    @State var detailScrollPosition: UUID? = nil
    
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 10) {
                GeometryReader {
                    let size = $0.size
                    ScrollView(.horizontal) {
                        HStack(spacing: 10) {
                            ForEach(mynewtemp) { pic in
                                LazyHStack {
                                    KFImage(URL(string: pic.photo))
                                        .resizable()
                                        .aspectRatio(contentMode: .fill)
                                        .frame(maxWidth: size.width)
                                        .overlay(content: {
                                            RoundedRectangle(cornerRadius: 10).stroke(Color.gray.opacity(0.4), lineWidth: 1.0)
                                        })
                                        .clipShape(RoundedRectangle(cornerRadius: 10))
                                        .contentShape(RoundedRectangle(cornerRadius: 10))
                                        .onTapGesture {
                                            withAnimation(.easeInOut(duration: 0.15)){
                                                detailScrollPosition = pic.id
                                            }
                                        }
                                }
                                .frame(maxWidth: size.width)
                                .frame(height: size.height)
                                .contentShape(.rect)
                            }
                        }
                        .scrollTargetLayout()
                    }
                    .scrollPosition(id: $detailScrollPosition, anchor: .center)
                    .scrollIndicators(.hidden)
                    .scrollTargetBehavior(.viewAligned)
                    .scrollClipDisabled()
                }.frame(height: 250)
            }
        }
    }
}

#Preview {
    TopPostMediaView()
}

let mynewtemp: [tempStruct] = [tempStruct(id: UUID(), photo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRmCy16nhIbV3pI1qLYHMJKwbH2458oiC9EmA&s"), tempStruct(id: UUID(), photo: "https://th.bing.com/th/id/OIG2.9O4YqGf98tiYzjKDvg7L"), tempStruct(id: UUID(), photo: "https://th.bing.com/th/id/OIG2.9O4YqGf98tiYzjKDvg7L")]

struct tempStruct: Identifiable, Hashable {
    var id: UUID
    var photo: String
}


Solution

  • It seems that ViewAlignedScrollTargetBehavior always aligns to the leading edge of the ScrollView and there is no way to specify a different anchor.

    When you set the scroll position using either .scrollPosition or ScrollViewProxy.scrollTo, it is possible to specify the anchor too. But this is unrelated to the scroll target behavior and doesn't have any impact on it.

    To solve, you probably need to implement your own ScrollTargetBehavior. For the case here, it is particularly difficult, because the container is lazy and every item can have a different size. But if you know the mid-X position of the currently selected image, this can be set as the target for scrolling to:

    struct ScrollTargetCentered: ScrollTargetBehavior {
        let currentMidX: CGFloat
    
        func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
    
            // Only perform an adjustment if the target anchor is
            // top-leading. This will be the case for a scroll action,
            // but may not be the case on initial show.
            if (target.anchor?.x ?? 0) == 0 {
                target.rect.origin.x = currentMidX - (context.containerSize.width / 2)
            }
        }
    }
    

    The image that is nearest the center is already being tracked using .scrollPosition and its id is saved to detailScrollPosition. So the mid-X position of the image with matching id can be recorded to a state variable as the view is scrolled, using a GeometryReader behind each image. The changes needed:

    struct TopPostMediaView: View {
        @State var detailScrollPosition: UUID? = nil
        @State private var currentMidX = CGFloat.zero // πŸ‘ˆ added
        @Namespace private var ns // πŸ‘ˆ added
    
        private func positionTracker(imageId: UUID) -> some View { // πŸ‘ˆ added
            GeometryReader { proxy in
                let midX: CGFloat? = imageId == detailScrollPosition
                    ? proxy.frame(in: .named(ns)).midX
                    : nil
                Color.clear
                    .onChange(of: midX) { oldVal, newVal in
                        if let newVal {
                            currentMidX = newVal
                        }
                    }
            }
        }
    
        var body: some View {
            ScrollView {
                VStack(alignment: .leading, spacing: 10) {
                    GeometryReader {
                        let size = $0.size
                        ScrollView(.horizontal) {
                            LazyHStack(spacing: 10) { // πŸ‘ˆ changed
                                ForEach(mynewtemp) { pic in
                                    // LazyHStack { // πŸ‘ˆ removed (including modifiers)
                                        // KFImage(URL(string: pic.photo))
                                        Image(pic.photo) // πŸ‘ˆ changed for testing
                                            .resizable()
                                            .aspectRatio(contentMode: .fill)
                                            .frame(maxWidth: size.width)
                                            .overlay(content: {
                                                RoundedRectangle(cornerRadius: 10).stroke(Color.gray.opacity(0.4), lineWidth: 1.0)
                                            })
                                            .clipShape(RoundedRectangle(cornerRadius: 10))
                                            .contentShape(RoundedRectangle(cornerRadius: 10))
                                            .onTapGesture {
                                                withAnimation(.easeInOut(duration: 0.15)){
                                                    detailScrollPosition = pic.id
                                                }
                                            }
                                            .background { positionTracker(imageId: pic.id) } // πŸ‘ˆ added
                                    // }
                                    // .frame(maxWidth: size.width)
                                    // .frame(height: size.height)
                                    // .contentShape(.rect)
                                }
                            }
                            .scrollTargetLayout()
                            .coordinateSpace(name: ns) // πŸ‘ˆ added
                        }
                        .scrollPosition(id: $detailScrollPosition, anchor: .center)
                        .scrollIndicators(.hidden)
                        // .scrollTargetBehavior(.viewAligned)
                        .scrollTargetBehavior( // πŸ‘ˆ changed
                            ScrollTargetCentered(currentMidX: currentMidX)
                        )
                        .scrollClipDisabled()
                    }.frame(height: 250)
                }
            }
        }
    }
    

    I found that the images you supplied in your example didn't always load properly, perhaps because images 2 + 3 have the same URL. So I tested with local images:

    let mynewtemp: [tempStruct] = [
    //    tempStruct(id: UUID(), photo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRmCy16nhIbV3pI1qLYHMJKwbH2458oiC9EmA&s"),
    //    tempStruct(id: UUID(), photo: "https://th.bing.com/th/id/OIG2.9O4YqGf98tiYzjKDvg7L"),
    //    tempStruct(id: UUID(), photo: "https://th.bing.com/th/id/OIG2.9O4YqGf98tiYzjKDvg7L")
        tempStruct(id: UUID(), photo: "image1"),
        tempStruct(id: UUID(), photo: "image2"),
        tempStruct(id: UUID(), photo: "image3"),
        tempStruct(id: UUID(), photo: "image4")
    ]
    

    Animation

    The custom scroll behavior performs quite well for slow scrolls, not so well for intertia scrolls. However, it won't work for narrow images at the start or end of the scrolled content. This is because a narrow image at an end won't reach the center of the screen, so detailScrollPosition will never be updated with its id. To resolve this, you could consider adding padding to the LazyVStack. See this answer for an example of where this technique is used.