SwiftUI ScrollView Content Movement with Keyboard

118 Views Asked by At

I'm currently working on a SwiftUI project and facing an issue with the ScrollView component. Specifically, I'm trying to implement functionality similar to WhatsApp or Telegram chat, where the content of the ScrollView moves up when the keyboard appears.

Below is a simplified example of what I'm trying to achieve:

        ZStack {
        ScrollViewReader { value in
            Button ("Move to #8") {
                withAnimation {
                    value.scrollTo(8)
                    selectedIndex = 8
                }
            }
            
            ScrollView(.vertical, showsIndicators: false) {
                LazyVStack(spacing: 10) {
                    ForEach(0...50, id: \.self) { index in
                        Text("Item \(index)")
                            .font(.title)
                            .foregroundStyle(.white)
                            .frame(width: height(index: index), height: height(index: index)) // frame for selected
                            .background(colors[index % colors.count])
                            .cornerRadius(8)
                            .id(index)
                    }
                }
                
            }
            .padding(.bottom, 50)
        }
        
        VStack {
            TextField("Message", text: $fullText)
            
        }
        .padding(.horizontal)
        .frame(maxHeight: .infinity, alignment: .bottom)
    }

In this example, I'm manually scrolling to a specific position using ScrollViewReader. However, my goal is for the last visible text to automatically move above the keyboard when it appears, and return to its original position when the keyboard disappears.

I've noticed that many developers encounter similar challenges with SwiftUI. If anyone has successfully implemented this behavior or has insights on how to achieve it, I would greatly appreciate your help.

1

There are 1 best solutions below

4
Javier Heisecke On

I recently did this by

  1. Having a KeyboardObserver that would notify me when the keyboard pops up and goes back down.
  2. Giving an id to my elements inside my ScrollView, make sure your list is sorted, otherwise it won't work
  3. Maintaining a Set of the elements on screen, to know where to scroll when the keyboard shows and hides

This is a quick implementation I did for your question, there's a lot to clean up but hopefully it still helps

    struct ContentView: View {
    
    @ObservedObject var viewModel = ContentViewModel()
    
    @State private var elements: [Int] = Array(0...50)
    @State private var elementsOnScreen: Set<Int> = []
    @State private var minElementScrollBack: Int?
    @State private var fullText: String = ""
    
    var body: some View {
        ZStack {
            ScrollViewReader { scrollViewContext in
                ScrollView(.vertical, showsIndicators: false) {
                    LazyVStack(spacing: 10) {
                        ForEach(elements, id: \.self) { number in
                            Text("Item \(number)")
                                .font(.title)
                                .foregroundStyle(.black)
                                .cornerRadius(8)
                                .id(number)
                                .onAppear {
                                    elementsOnScreen.insert(number)
                                }
                                .onDisappear {
                                    elementsOnScreen.remove(number)
                                }
                        }
                    }
                }
                .padding(.bottom, 50)
                .onTapGesture {
                    UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
                }
                .onReceive(viewModel.$isKeyboardShowing) { isShowingKeyboard in
                    if isShowingKeyboard {
                        minElementScrollBack = elementsOnScreen.min()
                        guard let lastId = elementsOnScreen.max() else { return }
                        /// Time it takes the keyboard to appear, if you scroll to lastId right away, sometimes has weird results, it's better to wait for the animation to end
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                            withAnimation {
                                scrollViewContext.scrollTo(lastId)
                            }
                        }
                    } else {
                        guard let scrollBack = minElementScrollBack else { return }
                        withAnimation {
                            scrollViewContext.scrollTo(scrollBack)
                        }
                        minElementScrollBack = nil
                    }
                }
            }
            
            VStack {
                TextField("Message", text: $fullText)
            }
            .padding(.horizontal)
            .frame(maxHeight: .infinity, alignment: .bottom)
        }
        .onAppear {
            viewModel.onAppear()
        }
    }
}

class ContentViewModel: ObservableObject, KeyboardObserver {
    
    @Published var isKeyboardShowing: Bool = false
    
    private lazy var keyboardHelper = KeyboardHelper()
    
    func onAppear() {
        keyboardHelper.observer = self
        keyboardHelper.startObserving()
    }
    
    func keyboardWillShow(animationDuration: Double?) {
        //TODO: you can use the animation to remove the hardcoded 0.3 seconds
        isKeyboardShowing = true
    }
    
    func keyboardWillHide() {
        isKeyboardShowing = false
    }
    
}

For the KeyboardObserver

    import UIKit

protocol KeyboardObserver: AnyObject {
    func keyboardWillShow(animationDuration: Double?)
    func keyboardWillHide()
}

class KeyboardHelper {
    
    weak var observer: KeyboardObserver?
    
    func startObserving() {
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(keyboardWillShow(notification:)),
                                               name: UIResponder.keyboardWillShowNotification,
                                               object: nil)
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(keyboardWillHide(notification:)),
                                               name: UIResponder.keyboardWillHideNotification,
                                               object: nil)
    }
    
    func stopObserving() {
        NotificationCenter.default.removeObserver(self)
    }
    
    @objc private
    func keyboardWillShow(notification: Notification) {
        observer?.keyboardWillShow(animationDuration: notification.animationDuration)
    }
    
    @objc private
    func keyboardWillHide(notification: Notification) {
        observer?.keyboardWillHide()
    }
}

extension Notification {
    var animationDuration: Double? {
        guard let info = userInfo,
            let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
            else { return nil }

        return duration
    }
}