iPhone X how to handle View Controller inputAccessoryView?

19k views Asked by At

I have a messaging app that has the typical UI design of a text field at the bottom of a full screen table view. I am setting that text field to be the view controller's inputAccessoryView and calling ViewController.becomeFirstResponder() in order to get the field to show at the bottom of the screen.

I understand this is the Apple recommended way of accomplishing this UI structure and it works perfectly on "classic" devices however when I test on the iPhone X simulator I notice that using this approach, the text field does not respect the new "safe areas". The text field is rendered at the very bottom of the screen underneath the home screen indicator.

I have looked around the the HIG documents but haven't found anything useful regarding the inputAccessoryView on a view controller.

It's difficult because using this approach I'm not actually in control of any of the constraints directly, I'm just setting the inputAccessoryView and letting the view controller handle the UI from there. So I can't just constrain the field to the new safe areas.

Has anyone found good documentation on this or know of an alternate approach that works well on the iPhone X?

enter image description here

16

There are 16 answers

5
Alexandre Bintz On BEST ANSWER

inputAccessoryView and safe area on iPhone X

  • when the keyboard is not visible, the inputAccessoryView is pinned on the very bottom of the screen. There is no way around that and I think this is intended behavior.

  • the layoutMarginsGuide (iOS 9+) and safeAreaLayoutGuide (iOS 11) properties of the view set as inputAccessoryView both respect the safe area, i.e on iPhone X :

    • when the keyboard is not visible, the bottomAnchor accounts for the home button area
    • when the keyboard is shown, the bottomAnchor is at the bottom of the inputAccessoryView, so that it leaves no useless space above the keyboard

Working example :

import UIKit

class ViewController: UIViewController {

    override var canBecomeFirstResponder: Bool { return true }

    var _inputAccessoryView: UIView!

    override var inputAccessoryView: UIView? {

        if _inputAccessoryView == nil {

            _inputAccessoryView = CustomView()
            _inputAccessoryView.backgroundColor = UIColor.groupTableViewBackground

            let textField = UITextField()
            textField.borderStyle = .roundedRect

            _inputAccessoryView.addSubview(textField)

            _inputAccessoryView.autoresizingMask = .flexibleHeight

            textField.translatesAutoresizingMaskIntoConstraints = false

            textField.leadingAnchor.constraint(
                equalTo: _inputAccessoryView.leadingAnchor,
                constant: 8
            ).isActive = true

            textField.trailingAnchor.constraint(
                equalTo: _inputAccessoryView.trailingAnchor,
                constant: -8
            ).isActive = true

            textField.topAnchor.constraint(
                equalTo: _inputAccessoryView.topAnchor,
                constant: 8
            ).isActive = true

            // this is the important part :

            textField.bottomAnchor.constraint(
                equalTo: _inputAccessoryView.layoutMarginsGuide.bottomAnchor,
                constant: -8
            ).isActive = true
        }

        return _inputAccessoryView
    }

    override func loadView() {

        let tableView = UITableView()
        tableView.keyboardDismissMode = .interactive

        view = tableView
    }
}

class CustomView: UIView {

    // this is needed so that the inputAccesoryView is properly sized from the auto layout constraints
    // actual value is not important

    override var intrinsicContentSize: CGSize {
        return CGSize.zero
    }
}

See the result here

0
He Yifei 何一非 On

After much research and trials, this seems to be a working solution for trying to use UIToolbar with a text input's inputAccessoryView. (Most of the existing solutions are for using fixed accessory view instead of assigning it to a text view (and hiding it when the keyboard is closed).)

The code is inspired by https://stackoverflow.com/a/46510833/2603230. Basically, we first create a custom view that has a toolbar subview:

class CustomInputAccessoryWithToolbarView: UIView {
    public var toolbar: UIToolbar!

    override init(frame: CGRect) {
        super.init(frame: frame)

        // https://stackoverflow.com/a/58524360/2603230
        toolbar = UIToolbar(frame: frame)

        // Below is adopted from https://stackoverflow.com/a/46510833/2603230
        self.addSubview(toolbar)

        self.autoresizingMask = .flexibleHeight

        toolbar.translatesAutoresizingMaskIntoConstraints = false
        toolbar.leadingAnchor.constraint(
            equalTo: self.leadingAnchor,
            constant: 0
        ).isActive = true
        toolbar.trailingAnchor.constraint(
            equalTo: self.trailingAnchor,
            constant: 0
        ).isActive = true
        toolbar.topAnchor.constraint(
            equalTo: self.topAnchor,
            constant: 0
        ).isActive = true
        // This is the important part:
        if #available(iOS 11.0, *) {
            toolbar.bottomAnchor.constraint(
                equalTo: self.safeAreaLayoutGuide.bottomAnchor,
                constant: 0
            ).isActive = true
        } else {
            toolbar.bottomAnchor.constraint(
                equalTo: self.layoutMarginsGuide.bottomAnchor,
                constant: 0
            ).isActive = true
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // https://stackoverflow.com/a/46510833/2603230
    // This is needed so that the inputAccesoryView is properly sized from the auto layout constraints.
    // Actual value is not important.
    override var intrinsicContentSize: CGSize {
        return CGSize.zero
    }
}

Then you can set it as an inputAccessoryView for a text input normally: (You should specify the frame size to avoid the warnings seen in UIToolbar with UIBarButtonItem LayoutConstraint issue)

let myAccessoryView = CustomInputAccessoryWithToolbarView(frame: CGRect(x: 0, y: 0, width: self.view.frame.size.width, height: 44))
textView.inputAccessoryView = myAccessoryView

When you want to interact with the toolbar (e.g., set items on the toolbar), you can simply refer to the toolbar variable:

myAccessoryView.toolbar.setItems(myToolbarItems, animated: true)

Demo: (with hardware keyboard / Command+K in simulator)

Before: the toolbar obstructs the home indicator

After: the toolbar does not obstruct the home indicator

0
Vlad On

From code (Swift 4). Idea - monitoring layoutMarginsDidChange event and adjusting intrinsicContentSize.

public final class AutoSuggestionView: UIView {

   private lazy var tableView = UITableView(frame: CGRect(), style: .plain)
   private var bottomConstraint: NSLayoutConstraint?
   var streetSuggestions = [String]() {
      didSet {
         if streetSuggestions != oldValue {
            updateUI()
         }
      }
   }
   var handleSelected: ((String) -> Void)?

   public override func initializeView() {
      addSubview(tableView)
      setupUI()
      setupLayout()
      // ...
      updateUI()
   }

   public override var intrinsicContentSize: CGSize {
      let size = super.intrinsicContentSize
      let numRowsToShow = 3
      let suggestionsHeight = tableView.rowHeight * CGFloat(min(numRowsToShow, tableView.numberOfRows(inSection: 0)))
      //! Explicitly used constraint instead of layoutMargins
      return CGSize(width: size.width,
                    height: suggestionsHeight + (bottomConstraint?.constant ?? 0))
   }

   public override func layoutMarginsDidChange() {
      super.layoutMarginsDidChange()
      bottomConstraint?.constant = layoutMargins.bottom
      invalidateIntrinsicContentSize()
   }
}

extension AutoSuggestionView {

   private func updateUI() {
      backgroundColor = streetSuggestions.isEmpty ? .clear : .white
      invalidateIntrinsicContentSize()
      tableView.reloadData()
   }

   private func setupLayout() {

      let constraint0 = trailingAnchor.constraint(equalTo: tableView.trailingAnchor)
      let constraint1 = tableView.leadingAnchor.constraint(equalTo: leadingAnchor)
      let constraint2 = tableView.topAnchor.constraint(equalTo: topAnchor)
      //! Used bottomAnchor instead of layoutMarginGuide.bottomAnchor
      let constraint3 = bottomAnchor.constraint(equalTo: tableView.bottomAnchor)
      bottomConstraint = constraint3
      NSLayoutConstraint.activate([constraint0, constraint1, constraint2, constraint3])
   }
}

Usage:

let autoSuggestionView = AutoSuggestionView()
// ...
textField.inputAccessoryView = autoSuggestionView

Result:

enter image description here enter image description here

1
Jakub Truhlář On

In the case you already have a custom view loaded via nib file.

Add a convenience constructor like this:

convenience init() {
    self.init(frame: .zero)
    autoresizingMask = .flexibleHeight
}

and override intrinsicContentSize:

override var intrinsicContentSize: CGSize {
    return .zero
}

In the nib set the first bottom constraint (of views that should stay above the safe area) to safeArea and the second one to superview with lower priority so it can be satisfied on older iOS.

0
Danil Yusupov On

I'm just add safe area to inputAccessoryView (checkbox at Xcode). And change bottom space constraint equal to bottom of safe area instead of inputAccessoryView root view bottom.

Constraint

And result

0
Marat Saytakov On

Seems it's an iOS bug, and there is a rdar issue for it: inputAccessoryViews should respect safe area inset with external keyboard on iPhone X

I guess this should be fixed in iOS update when iPhone X will come up.

15
ahbou On

This is a general issue with inputAccessoryViews on iPhone X. The inputAccessoryView ignores the safeAreaLayoutGuides of its window.

To fix it we have to manually add the constraint in your class when the view moves to its window:

override func didMoveToWindow() {
    super.didMoveToWindow()
    if #available(iOS 11.0, *) {
        if let window = self.window {
            self.bottomAnchor.constraintLessThanOrEqualToSystemSpacingBelow(window.safeAreaLayoutGuide.bottomAnchor, multiplier: 1.0).isActive = true
        }
    }
}

PS: self here is referring to the inputAccessoryView.

I wrote about it in detail here: http://ahbou.org/post/165762292157/iphone-x-inputaccessoryview-fix

0
jki On

Until safe are insets are guided by iOS automatically, simple workaround would be to wrap your accessory in container view and set bottom space constraint between accesory view and container view to match safe area insets of window.

Note: Of course this workaround can double your accessory view spacing from bottom when iOS update fixes bottom spacing for accessory views.

E.g.

- (void) didMoveToWindow {
    [super didMoveToWindow];
    if (@available(iOS 11.0, *)) {
        self.bottomSpaceConstraint.constant = self.window.safeAreaInsets.bottom;
    }
}
0
Jeff On

I just created a quick CocoaPod called SafeAreaInputAccessoryViewWrapperView to fix this. It also dynamically sets the wrapped view's height using autolayout constraints so you don't have to manually set the frame. Supports iOS 9+.

Here's how to use it:

  1. Wrap any UIView/UIButton/UILabel/etc using SafeAreaInputAccessoryViewWrapperView(for:):

    SafeAreaInputAccessoryViewWrapperView(for: button)
    
  2. Store a reference to this somewhere in your class:

    let button = UIButton(type: .system)
    
    lazy var wrappedButton: SafeAreaInputAccessoryViewWrapperView = {
        return SafeAreaInputAccessoryViewWrapperView(for: button)
    }()
    
  3. Return the reference in inputAccessoryView:

    override var inputAccessoryView: UIView? {
        return wrappedButton
    }
    
  4. (Optional) Always show the inputAccessoryView, even when the keyboard is closed:

    override var canBecomeFirstResponder: Bool {
        return true
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        becomeFirstResponder()
    }
    

Good luck!

2
Raghav Ahuja On

I just created a project on Github with support for iPhone X. It respects the new safe area layout guide. Use:

autoresizingMask = [.flexibleHeight]

Screenshot:

screenshot

0
Yuriy Pavlyshak On

Solution that worked for me without workarounds:

I'm using UIInputViewController for providing input accessory view by overriding inputAccessoryViewController property instead of inputAccessoryView in the "main" view controller.

UIInputViewController's inputView is set to my custom input view (subclass of UIInputView).

What actually did the trick for me is setting allowsSelfSizing property of my UIInputView to true. The constraints inside input view use safe area and are set up in a way that defines total view's height (similar to autoresizing table view cells).

0
Edward Anthony On

The simplest answer (with just one line of code)

Simply use a custom view that inherits from UIToolbar instead of UIView as your inputAccessoryView.

Also don't forget to set that custom view's autoresizing mask to UIViewAutoresizingFlexibleHeight.

That's it. Thank me later.

0
Shockki On

Have a view hierarchy where you have a container view and a content view. The container view can have a background color or a background view that encompasses its entire bounds, and it lays out its content view based on safeAreaInsets. If you’re using autolayout, this is as simple as setting the content view’s bottomAnchor to be equal to its superview’s safeAreaLayoutGuide.

1
Vlad On

In Xib, find a right constraint at the bottom of your design, and set item to Safe Area instead of Superview:

Before: enter image description here

Fix: enter image description here

After: enter image description here

0
ERbittuu On

Just add one extension for JSQMessagesInputToolbar

extension JSQMessagesInputToolbar {
    override open func didMoveToWindow() {
        super.didMoveToWindow()
        if #available(iOS 11.0, *) {
            if self.window?.safeAreaLayoutGuide != nil {
            self.bottomAnchor.constraintLessThanOrEqualToSystemSpacingBelow((self.window?.safeAreaLayoutGuide.bottomAnchor)!,
                                                                            multiplier: 1.0).isActive = true
            }
        }
     }
}

duplicate : jsqmessageviewcontroller ios11 toolbar

0
Tulleb On

-- For those who are using the JSQMessagesViewController lib --

I am proposing a fixed fork based on the JSQ latest develop branch commit.

It is using the didMoveToWindow solution (from @jki I believe?). Not ideal but worth to try while waiting for Apple's answer about inputAccessoryView's safe area layout guide attachment, or any other better fix.

You can add this to your Podfile, replacing the previous JSQ line:

pod 'JSQMessagesViewController', :git => 'https://github.com/Tulleb/JSQMessagesViewController.git', :branch => 'develop', :inhibit_warnings => true