iosswiftuiscrollview

SwiftUI align view to center scrollView


My task is to make a scrollview, which receives an array of strings and an overlay, which is located in the center. When some view gets into this overlay, it should immediately align to its borders, that is, be in the center of the screen. The list should start from the middle of the screen with the first element. I was looking for a solution here text

I have struct

struct Region: Equatable, Hashable {
    let name: String
}

PreferenceKeyStruct

struct ViewOffsetKey: PreferenceKey {
    static var defaultValue: [String: CGRect] = [:]
    
    static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
        value.merge(nextValue(), uniquingKeysWith: { $1 })
    }
}

and subView

struct RegionView: View {
    
    let name: String
    
    var body: some View {
        HStack {
            Text(self.name)
                .foregroundColor(.white)
                .font(.system(.body))
            
            Spacer()
        }
        .padding(16)
        .frame(width: 348, alignment: .topLeading)
        .background(Color.black)
        .cornerRadius(50)
        .id(name)
        .background(GeometryReader {
            Color.clear.preference(key: ViewOffsetKey.self,
                                   value: [name: $0.frame(in: .global)])
        })
    }
}

The main view is realized through CurrentValueSubject and AnyPublisher. I check if subView intersects a rectangle with a height of 1, then I write it as displayed. When the scroll ends, I call the scrollTo method

struct RegionListView: View {
    let detector: CurrentValueSubject<[String: CGRect], Never>
    let publisher: AnyPublisher<[String: CGRect], Never>
    
    let regions: [Region] = [
        Region(name: "Region 1"),
        Region(name: "Region 2"),
        Region(name: "Region 3"),
        Region(name: "Region 4"),
        Region(name: "Region 5"),
        Region(name: "Region 6"),
        Region(name: "Region 7"),
        Region(name: "Region 8"),
        Region(name: "Region 9"),
        Region(name: "Region 10"),
        Region(name: "Region 11"),
        Region(name: "Region 12"),
        Region(name: "Region 13"),
        Region(name: "Region 14"),
        Region(name: "Region 15"),
        Region(name: "Region 16"),
        Region(name: "Region 17"),
        Region(name: "Region 18"),
        Region(name: "Region 19"),
        Region(name: "Region 20"),
    ]
    
    @State private var topVisibleChildId: String?
    
    init() {
        let detector = CurrentValueSubject<[String: CGRect], Never>([:])
        self.publisher = detector
            .debounce(for: .seconds(0.1), scheduler: DispatchQueue.main)
            .dropFirst()
            .eraseToAnyPublisher()
        self.detector = detector
    }
    
    var body: some View {
        GeometryReader { geometryReader in
            ScrollViewReader { proxy in
                ScrollView {
                    VStack(spacing: 8) {
                        ForEach(self.regions, id: \.self) { region in
                            RegionView(name: region.name)
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .onPreferenceChange(ViewOffsetKey.self) { childFrames in
                        detector.send(childFrames)
                        
                        var visibleChildIds = [String]()
                        let screenMid = geometryReader.size.height / 2 + 56
                        
                        for (id, childFrame) in childFrames where childFrame.intersects(CGRect(x: 0, y: Int(screenMid), width: .max, height: 56)) {
                            print("id \(id) childFrame \(childFrame)")
                            visibleChildIds.append(id)
                        }
                        
                        visibleChildIds.sort()
                        
                        if let first = visibleChildIds.first {
                            topVisibleChildId = first
                        }
                    }
                }
                .safeAreaPadding(.top, geometryReader.size.height / 2 - 28)
                .safeAreaPadding(.bottom, geometryReader.size.height / 2 - 28)
                .background(Color.black)
                .onReceive(publisher) { _ in
                    proxy.scrollTo(topVisibleChildId, anchor: .center)
                }
                .overlay(
                    Text("Top Visible Child: \(topVisibleChildId ?? "")")
                        .padding()
                        .background(Color.blue.opacity(1))
                        .foregroundColor(.white)
                        .cornerRadius(10),
                    alignment: .top
                )
                
                .overlay(
                    Rectangle()
                        .frame(maxWidth: .infinity)
                        .frame(height: 56)
                        .foregroundColor(Color.clear)
                        .border(.green, width: 4),
                    alignment: .center
                )
            }
        }
    }
}

My question: is it possible to implement a behavior in which changing cells will be similar to choosing a time when setting an alarm. That is, the cell will not have to be scrolled to the center if the user did not finish scrolling in the middle. I hope I explained it clearly.


Solution

  • It sounds like you want the scroll view to snap to a view when it finishes scrolling, then you want to be able to detect which of the views is under the overlay.

    There are various modifiers that provide this functionality for free:

    In order that the scroll view is positioned with the first of the views positioned in the center when it first appears, add .contentMargins to pad the scrolled content.

    Btw, your example is using quite a lot of deprecated modifiers. You might want to consider the following replacements:

    Here is the updated example to show it all working:

    struct RegionView: View {
        let name: String
    
        var body: some View {
            Text(name)
                .foregroundStyle(.white)
                .font(.body)
                .padding(16)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
    
    struct RegionListView: View {
    //    let detector: CurrentValueSubject<[String: CGRect], Never>
    //    let publisher: AnyPublisher<[String: CGRect], Never>
    
        let regions: [Region] = [
            Region(name: "Region 1"),
            Region(name: "Region 2"),
            Region(name: "Region 3"),
            Region(name: "Region 4"),
            Region(name: "Region 5"),
            Region(name: "Region 6"),
            Region(name: "Region 7"),
            Region(name: "Region 8"),
            Region(name: "Region 9"),
            Region(name: "Region 10"),
            Region(name: "Region 11"),
            Region(name: "Region 12"),
            Region(name: "Region 13"),
            Region(name: "Region 14"),
            Region(name: "Region 15"),
            Region(name: "Region 16"),
            Region(name: "Region 17"),
            Region(name: "Region 18"),
            Region(name: "Region 19"),
            Region(name: "Region 20"),
        ]
        let overlayHeight: CGFloat = 56
        @State private var topVisibleChildId: String?
    
        var body: some View {
            GeometryReader { proxy in
                ScrollView {
                    VStack(spacing: 0) {
                        ForEach(regions, id: \.name) { region in
                            RegionView(name: region.name)
                                .frame(maxHeight: overlayHeight)
                        }
                    }
                    .scrollTargetLayout()
                }
                .scrollPosition(id: $topVisibleChildId, anchor: .top)
                .scrollTargetBehavior(.viewAligned)
                .contentMargins(.vertical, (proxy.size.height - overlayHeight) / 2)
                .background(.black)
                .overlay(alignment: .top) {
                    Text("Top Visible Child: \(topVisibleChildId ?? "")")
                        .padding()
                        .foregroundStyle(.white)
                        .background(.blue, in: .rect(cornerRadius: 10))
                }
                .overlay {
                    Rectangle()
                        .strokeBorder(.green, lineWidth: 4)
                        .frame(height: overlayHeight)
                }
                .onAppear {
                    topVisibleChildId = regions.first?.name
                }
            }
        }
    }
    

    Animation