UITableView scroll to bottom with InputAccessoryView

69 Views Asked by At

I am trying to build a chat layout and can't get the remaining 5% to achieve a smooth UI. The goal is to create a chat layout like WhatsApp or iMessage where the last message is immediatly shown over the input bar (which is very likely an InputAccessoryView with keyboardDismissMode = .interactive). The inputBar should be able to show multiple lines and the chat cells should be dynamic in their height (Their content can be very dynamic). Everything should also dock to the keyboard.

I feel like I tried everything but I can't get a smooth UI to work. I tried collectionViews/tableViews in a UIViewController and plain UITableViewController and UICollectionViewController. It either flickers or doesn't scroll fully to the bottom or the scroll is delayed or animated, even if I disable animations. I just want the chat to be displayed instantly on the bottom above the inputBar. I created a new project to reproduce my issues and simplified everything and still can't get it to work properly. The following example is a UIViewController with an embedded tableView with a local array of strings. The issue here is that it won't scroll to the last item. Just a bit above. I think this happens, because the inputAccessoryView is not shown instantly and/or I adjust the keyboard constraint in the viewDidLoad method.

The last cell is not shown completely

Code:

Here I initialize the tableView and inputAccessoryView. In production I use the InputBarAccessoryView library for multiline input.

    @IBOutlet weak var tableView: UITableView!
    private lazy var dataSource = makeDataSource()
    
    private var textField: UITextField?
    override var inputAccessoryView: UIView? {
        if textField == nil {
            textField = UITextField(frame: CGRect(x: 0, y: 0, width: 500, height: 80))
            textField?.backgroundColor = .systemBlue
            textField?.placeholder = "Enter message here"
            textField?.borderStyle = .line
        }
        return textField
    }
    
    override var canBecomeFirstResponder: Bool {
        return true
    }

In the viewDidLoad method I set the tableView configuration, keyboardListener and keyboard constraint and apply a snapshot with a non animated scroll to the bottom. The tableView in the .xib file has constraints to the superview but the bottom constraint will be removed during build time to get overriden by a keyboard constraint. I know that applying the keyboard constraint will trigger a re-layout from the view.

    override func viewDidLoad() {
        super.viewDidLoad()
    
        tableView.keyboardDismissMode = .interactive
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 80
        tableView.dataSource = dataSource
        tableView.register(UINib(nibName: "ChatBubbleTableViewCell", bundle: nil), forCellReuseIdentifier: "ChatBubbleTableViewCell")
        
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
        
        if #available(iOS 15, *) {
            view.keyboardLayoutGuide.topAnchor.constraint(equalTo: tableView!.bottomAnchor).isActive = true
        }
        
        DispatchQueue.main.async {
            self.applySnapshot(animatingDifferences: false)
            self.scrollToBottom(animated: false)
        }
    }

I call becomeFirstResponder in viewWillAppear

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        becomeFirstResponder()
    }

And here are the methods for the dataSource, snapshot applying and bottom scrolling. The TableViewCell is really simple with an UILabel constraint to all sides with some padding.

    private func makeDataSource() -> UITableViewDiffableDataSource<Section, String> {
        let dataSource = UITableViewDiffableDataSource<Section, String>(tableView: tableView) { tableView, indexPath, itemIdentifier in
            let cell = tableView.dequeueReusableCell(withIdentifier:  "ChatBubbleTableViewCell", for: indexPath) as! ChatBubbleTableViewCell
            cell.messageLabel.text = itemIdentifier
            return cell
        }
        return dataSource
    }

    private func scrollToBottom(animated: Bool) {
        guard !chatData.isEmpty else { return }
        let lastIndex = chatData.count - 1
        tableView.scrollToRow(at: IndexPath(row: lastIndex, section: 0), at: .bottom, animated: animated)
    }
    
    private func applySnapshot(animatingDifferences: Bool = true, completion: (() -> Void)? = nil) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
        snapshot.appendSections([.main])
        snapshot.appendItems(chatData, toSection: .main)
        dataSource.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion)
    }

I often tried to play with configuration in the viewDidLayoutSubviews method but then I often get delayed scrolling etc. It should not be this hard to achieve the satisfying layout but every combination I try is just not fulfulling the needed requirements. Thanks for help in advance.

0

There are 0 best solutions below