How do I get the ScrollView to keep its position when the keyboard appears with iOS 15/Xcode 13?

It actually is keeping its position. It is just that the window that you are viewing it through has moved. If you look at the top, you can see that those rows are still the same. What you want to do is to move the row that was at the bottom up. How you do this is with a ScrollViewReader. With the example given, it is pretty simple:

struct TestKeyboardScrollView2: View {
    @State var textfield: String = ""
    @FocusState private var keyboardVisible: Bool
    
    var body: some View {
        VStack {
            ScrollViewReader { scroll in
                ScrollView {
                    LazyVStack {
                        ForEach(1...100, id: \.self) { index in
                            Text("Row \(index)")
                        }
                    }
                }
                .onChange(of: keyboardVisible, perform: { _ in
                    if keyboardVisible {
                        withAnimation(.easeIn(duration: 1)) {
                            scroll.scrollTo(100) // this would be your array.count - 1,
                            // but you hard coded your ForEach
                        }
                        // The scroll has to wait until the keyboard is fully up
                        // this causes it to wait just a bit.
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                            // this helps make it look deliberate and finished
                            withAnimation(.easeInOut(duration: 1)) {
                                scroll.scrollTo(100) // this would be your array.count - 1,
                                // but you hard coded your ForEach
                            }
                        }
                    }
                })
            }
            TextField("Write here...", text: $textfield)
                .focused($keyboardVisible)
        }
    }
}

edit:

I have been able to resolve this 99%. The key was being able to determine when a view was actually on screen. This was done by a combination of knowing when the view had been scrolled, and then testing each view to see if it was within the visible area of the parent. This view also keeps track of when the view is scrolling. When the scrolling ends, the view takes the last row in the list, and anchor it to the bottom of the screen. This will cause the row to lock in fully on screen. This behavior can be disable by removing the .onReceive(publisher). The keyboard height is also kept track of, and any time it is greater than zero, the list of on screen rows is locked, and the last row is anchored to the bottom again once the keyboard is fully up through a delay. The same thing happens in reverse when the keyboard is dismissed, and the lock is again removed when the keyboard height hits 0. The code is commented, but any question please ask.

struct ListWithSnapTo: View {
    
    @State var messages = Message.dataArray()
    
    @State var textfield: String = ""
    @State var visibileIndex: [Int:Message] = [:]
    @State private var keyboardVisible = false
    @State private var readCells = true
    
    let scrollDetector: CurrentValueSubject<CGFloat, Never>
    let publisher: AnyPublisher<CGFloat, Never>
    
    init() {
        // This sets a publisher to keep track of whether the view is scrolling
        let detector = CurrentValueSubject<CGFloat, Never>(0)
        self.publisher = detector
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .dropFirst()
            .eraseToAnyPublisher()
        self.scrollDetector = detector
    }
    
    var body: some View {
        GeometryReader { outerProxy in
            ScrollViewReader { scroll in
                List {
                    ForEach(Array(zip(messages.indices, messages)), id: \.1) { (index, message) in
                        GeometryReader { geometry in
                            Text(message.messageText)
                            // These rows fill in from the bottom to the top
                                .onChange(of: geometry.frame(in: .named("List"))) { innerRect in
                                    if readCells {
                                        if isInView(innerRect: innerRect, isIn: outerProxy) {
                                            visibileIndex[index] = message
                                        } else {
                                            visibileIndex.removeValue(forKey: index)
                                        }
                                    }
                                }
                        }
                    }
                    // The preferenceKey keeps track of the fact that the view is scrolling.
                    .background(GeometryReader {
                        Color.clear.preference(key: ViewOffsetKey.self,
                                               value: -$0.frame(in: .named("List")).origin.y)
                    })
                    .onPreferenceChange(ViewOffsetKey.self) { scrollDetector.send($0) }
                    
                }
                // This tages the list as a coordinate space I want to use in a geometry reader.
                .coordinateSpace(name: "List")
                .onAppear(perform: {
                    // Moves the view so that the cells on screen are recorded.
                    scroll.scrollTo(messages[0], anchor: .top)
                })
                // This keeps track of whether the keyboard is up or down by its actual appearance on the screen.
                // The change in keyboardVisible allows the reset for the last cell to be set just above the keyboard.
                // readCells is a flag that prevents the scrolling from changing the last view.
                .onReceive(Publishers.keyboardHeight) { keyboardHeight in
                    if keyboardHeight > 0 {
                        keyboardVisible = true
                        readCells = false
                    } else {
                        keyboardVisible = false
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                            readCells = true
                        }
                    }
                }
                // This keeps track of whether the view is scrolling. If it is, it waits a bit,
                // and then sets the last visible message to the very bottom to snap it into place.
                // Remove this if you don't want this behavior.
                .onReceive(publisher) { _ in
                    if !keyboardVisible {
                        guard let lastVisibleIndex = visibileIndex.keys.max(),
                              let lastVisibleMessage = visibileIndex[lastVisibleIndex] else { return }
                        withAnimation(.easeOut) {
                            scroll.scrollTo(lastVisibleMessage, anchor: .bottom)
                        }
                    }
                }
                .onChange(of: keyboardVisible) { _ in
                    guard let lastVisibleIndex = visibileIndex.keys.max(),
                          let lastVisibleMessage = visibileIndex[lastVisibleIndex] else { return }
                    if keyboardVisible {
                        // Waits until the keyboard is up. 0.25 seconds seems to be the best wait time.
                        // Too early, and the last cell hides behind the keyboard.
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
                            // this helps make it look deliberate and finished
                            withAnimation(.easeOut) {
                                scroll.scrollTo(lastVisibleMessage, anchor: .bottom)
                            }
                        }
                    } else {
                        withAnimation(.easeOut) {
                            scroll.scrollTo(lastVisibleMessage, anchor: .bottom)
                        }
                    }
                }
                
                TextField("Write here...", text: $textfield)
            }
        }
        .navigationTitle("Scrolling List")
    }
    
    private func isInView(innerRect:CGRect, isIn outerProxy:GeometryProxy) -> Bool {
        let innerOrigin = innerRect.origin.y
        // This is an estimated row height based on the height of the contents plus a basic amount for the padding, etc. of the List
        // Have not been able to determine the actual height of the row. This may need to be adjusted.
        let rowHeight = innerRect.height + 22
        let listOrigin = outerProxy.frame(in: .global).origin.y
        let listHeight = outerProxy.size.height
        if innerOrigin + rowHeight < listOrigin + listHeight && innerOrigin > listOrigin {
            return true
        }
        return false
    }
}

extension Notification {
    var keyboardHeight: CGFloat {
        return (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0
    }
}

extension Publishers {
    static var keyboardHeight: AnyPublisher<CGFloat, Never> {
        let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
            .map { $0.keyboardHeight }
        let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification)
            .map { _ in CGFloat(0) }
        return MergeMany(willShow, willHide)
            .eraseToAnyPublisher()
    }
}

struct ViewOffsetKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}

struct Message: Identifiable, Hashable {
    let id = UUID()
    let messageText: String
    let date = Date()
    
    static func dataArray() -> [Message] {
        var messArray: [Message] = []
        for i in 1...100 {
            messArray.append(Message(messageText: "message \(i.description)"))
        }
        return messArray
    }
}

Leave a Comment