モノトーンの伝説日記

Apex Legends, Splatoon, Programming, and so on...

inputAccessoryView に入れた UIToolbar を SafeArea に考慮させる(キーボード接続時のための対応)

 おはようございます,モノトーンです。

 今日は,検索してもあまり出てこなかった,inputAccessoryView に入れた UIToolbar 自体に Safearea を考慮させる hack です。

f:id:mntone:20190130104513p:plain:w320

1. UIToolbar の内部構造

 ざっとこんな感じです。

f:id:mntone:20190130104442p:plain

 これを見る限り constraint を更新すれば,問題なさそうですね。

2. 大まかな流れ

  1. Safe area の変化を捉えられるように,insetsLayoutMarginsFromSafeArea を有効化。
  2. safeAreaInsetsDidChange を捉え,invalidateIntrinsicContentSize で UIToolbar のサイズ,setNeedsUpdateConstraints で制約を更新が必要と通知。
  3. updateConstraints で,UIStackView (厳密には内部クラス).bottom == _UIToolbarContentView.bottom となる制約を見つけ,それを UIStackView.bottom == _UIToolbarContentView.bottom - SafeAreaBottom とする。

といった感じ。難しくはないですね。

3. コード

// MIT license でご自由にご利用ください
import class Foundation.NSCoder
import struct UIKit.CGFloat
import struct UIKit.CGRect
import struct UIKit.CGSize
import class UIKit.NSLayoutConstraint
import class UIKit.UIToolbar
import class UIKit.UIView

@available(iOS 11.0, *)
public class UISafeToolbar: UIToolbar {
    private static let toolbarHeight: CGFloat = 44
    
    private weak var toolbarContentView: UIView!
    
    public convenience init() {
        self.init(frame: .zero)
    }
    
    public override init(frame: CGRect) {
        super.init(frame: .zero)
        self.insetsLayoutMarginsFromSafeArea = true
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.insetsLayoutMarginsFromSafeArea = true
    }
    
    public override func didAddSubview(_ subview: UIView) {
        super.didAddSubview(subview)
        
        let viewClassString = String(describing: type(of: subview))
        if viewClassString == "_UIToolbarContentView" {
            toolbarContentView = subview
        }
    }
    
    public override func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()
        
        invalidateIntrinsicContentSize()
        setNeedsUpdateConstraints()
    }
    
    public override func updateConstraints() {
        super.updateConstraints()
        
        for constraint in toolbarContentView.constraints {
            if isBottomConstraint(constraint) {
                constraint.constant = -safeAreaInsets.bottom
                break
            }
        }
    }
    
    public override var intrinsicContentSize: CGSize {
        let height = UISafeToolbar.toolbarHeight + safeAreaInsets.bottom
        return CGSize(width: bounds.width, height: height)
    }
    
    private func isBottomConstraint(_ constraint: NSLayoutConstraint) -> Bool {
        return constraint.firstAttribute == .bottom &&
               constraint.relation == .equal &&
               constraint.secondItem === toolbarContentView &&
               constraint.secondAttribute == .bottom
    }
}

3.1 利用方法

 OS で分岐してやればいいです

let toolbar: UIToolbar
if #available(iOS 11.0, *) {
    toolbar = UISafeToolbar()
} else {
    toolbar = UIToolbar(frame: .zero)
}
toolbar.autoresizingMask = [.flexibleHeight]
toolbar.items = items
yourTextView.inputAccessoryView = toolbar

 実行結果は次の通り。

f:id:mntone:20190130105455p:plain:w400 f:id:mntone:20190130104513p:plain:w400

f:id:mntone:20190130105517p:plain

f:id:mntone:20190130105532p:plain

まとめ

 そんなに難しくないのに,検索に乗ってこないのは意外でした。

 iOS 12 しか動作確認していませんが,iOS 11 と内部構造は変わってないと思うので,多分大丈夫なはず。

 現実問題として,UIToolbar そのままで使いたいケースもあると思うので,そのままの使用感で使える hack として実装してみました。