2

I'm implementing a messaging interface for my app. But, when I click on TextField to bring up the keyboard, the ScrollView does not keep the last message to bottom of the ScrollView bound. In other words, the keyboard will "cover" a couple of the latest messages, I'd have to scroll up manually to see the latest messages (see video).

I've tried to use ScrollViewReader to scrollTo to the last message once the TextField is focused, but would have to use an async DispatchQueue function with delay -- meaning, the messages will be scrolled to the right position after the keyboard has moved to its place.

This is quite unnatural compared to iMessage, Twitter, and basically all other apps that have messaging functions.

How do I solve this problem?

PipEvangelist
  • 601
  • 1
  • 7
  • 22
  • Does [this](https://stackoverflow.com/a/69500827/7129318) answer your question? – Yrb Jan 06 '22 at 02:10
  • Hi, @Yrb, I actually tried your solution. Unfortunately, as described in my post, the `scrollTo` only gets rendered **after** the keyboard has appeared. If you have Twitter, take a look at how their DM works. Ideally, I want the the last message to move in tangent with the keyboard pop-up animation. – PipEvangelist Jan 06 '22 at 02:19
  • If you read the comments, you are trying to do something in SwiftUI that is being done in UIKit in those apps. There is a gap here in what SiwftUI can do. If you want the other, you have to implement it in UIKit. – Yrb Jan 06 '22 at 12:29

1 Answers1

0

Works for iOS 15 with Swift 5.2

External Package Required: Introspect

I figured this out after sifting through multiple SO answers. The main idea is from this SO answer, but in his code, he used something like Publishers that just doesn't work on my end. Then, I figured out that he probably meant a keyboard-height observer that conforms to Publisher protocol.

KeyboardResponder (to get and broadcast keyboard height)

Not my code. All credit to to this SO answer:

import SwiftUI

final class KeyboardResponder: ObservableObject {
    private var notificationCenter: NotificationCenter
    @Published private(set) var currentHeight: CGFloat = 0

    init(center: NotificationCenter = .default) {
        notificationCenter = center
        notificationCenter.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
        notificationCenter.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
    }

    deinit {
        notificationCenter.removeObserver(self)
    }

    @objc func keyBoardWillShow(notification: Notification) {
        if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
            currentHeight = keyboardSize.height
        }
    }

    @objc func keyBoardWillHide(notification: Notification) {
        currentHeight = 0
    }
}

View

Then, in my view struct, I use introspectScrollView to get access to UIScrollView, which would allow me to modify the content offsets based on data received from the KeyboardResponder class.

import Introspect
import SwiftUI

struct ContentView: View {
    
    @ObservedObject var keyboard = KeyboardResponder()
    @State private var keyboardHeight: CGFloat = 0
    @State private var scrollView: UIScrollView? = nil
    
    var body: some View {
        VStack{
            ScrollView {
                // ...
            }.introspectScrollView {scrollView = $0}
            YourTextField(...)
        }
        .onReceive(keyboard.$currentHeight) { height in
            // I use a conditional unwrap because in my app, the Scrollview above
            // is conditional depending on whether there are messeges fetched
            // from the server, which means the scrollView will be nil initially
            if let _ = scrollView {
                
                // The following conditional check makes sure the content offset does not reset when user starts typing
                   if height > 0 && (TextInYourTextField == "" || keyboardHeight == 0) {
                       self.scrollView!.setContentOffset(CGPoint(x: 0, y: self.scrollView!.contentOffset.y + height), animated: true)
                   } else {
                       // For my app, I don't need the following but maybe helfpful for your situation
                       //self.scrollView!.contentOffset.y = max(self.scrollView!.contentOffset.y - keyboardHeight, 0)
                   }
                   keyboardHeight = height
            }
        }
    }
}

The .onReceive() is applied to the VStack, NOT on the ScrollView.

Minor Issues

The only thing that's bothering me is that my console keeps streaming things like:

ignoring singular matrix: ProjectionTransform(m11: 5e-324, m12: 0.0, m13: 0.0, m21: 0.0, m22: 5e-324, m23: 0.0, m31: 24.666666666666664, m32: 25.333333333333332, m33: 1.0)
ignoring singular matrix: ProjectionTransform(m11: 5e-324, m12: 0.0, m13: 0.0, m21: 0.0, m22: 5e-324, m23: 0.0, m31: 24.666666666666664, m32: 25.333333333333332, m33: 1.0)
ignoring singular matrix: ProjectionTransform(m11: 5e-324, m12: 0.0, m13: 0.0, m21: 0.0, m22: 5e-324, m23: 0.0, m31: 24.666666666666664, m32: 25.333333333333332, m33: 1.0)
ignoring singular matrix: ProjectionTransform(m11: 5e-324, m12: 0.0, m13: 0.0, m21: 0.0, m22: 5e-324, m23: 0.0, m31: 24.666666666666664, m32: 25.333333333333332, m33: 1.0)

I'm still trying to figure out whether this should concern me and if not, how to mute it.

PipEvangelist
  • 601
  • 1
  • 7
  • 22