iOS
ios11
Xcode9
iPhoneX

safeAreaInsetsの変更を感知する

はじめに

iPhone Xの登場により、Xcode 9からSafe Areaの概念が登場しました。
safeAreaInsets自体はview.safeAreaInsetsで取得可能ですが、画面回転やStatusBarの表示/非表示時などにsafeAreaInsetsに変更があった場合、どのようして感知すればの良いのかをメモ程度に書いていきます。

safeAreaInsetsの変更を感知する方法

この投稿では3つの方法を紹介しようと思います。

UIViewのsubclass

下記のようにUIViewのsubclassで、safeAreaInsetsの変更を感知できるようにしようと思います。

class ViewController: UIViewController {
    ...
    override func viewDidLoad() {
        super.viewDidLoad()
        ...    
        (view as? SafeAreaView)?.didChangeSafeAreaInsets = { insets in
            print("view.safeAreaInsets = \(insets)")
        }
    }
}

実装

下記のようにUIViewのsubclassを作成し、func safeAreaInsetsDidChange()をoverrideして、変更を感知できるようにしています。

class SafeAreaView: UIView {
    var didChangeSafeAreaInsets: ((UIEdgeInsets) -> ())?

    @available(iOS 11, *)
    override func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()
        didChangeSafeAreaInsets?(safeAreaInsets)
    }
}

StoryboardのIdentity Inspectorから、ViewControllerのviewをUIViewからSafeAreaViewに変更します。

スクリーンショット 2017-10-14 12.16.58.png

この方法を利用する場合、UIScrollView、UITableView...などそれぞれでsubclassを実装する必要があります。

UIViewを拡張

下記のようにUIViewのextensionで、safeAreaInsetsの変更を感知できるようにしようと思います。

class ViewController: UIViewController {
    ...
    override func viewDidLoad() {
        super.viewDidLoad()
        ...    
        view.safeArea.insetsDidChange = { insets in
            print("view.safeAreaInsets = \(insets)")
        }
    }
}

実装

まずは、Safe Areaに関するextensionをName Speceで区別できるよう、SafeAreaExtensionを作成します。
実際に利用するものは、var insetsDidChange: ((UIEdgeInsets) -> ())? { get set }になります。

// MARK: - extension
@available(iOS 11, *)
public class SafeAreaExtension {
    public var insetsDidChange: ((UIEdgeInsets) -> ())?
    private weak var view: UIView?
    fileprivate init() {
        self.view = view
    }
}

UIViewのextensionとして、SafeAreaExtensionのpropertyを追加します。
insetsDidChangeを保持させたいので、SafeAreaExtensionをAssociatedObjectとしています。

extension UIView {
    @available(iOS 11, *)
    private struct AssociatedKey {
        static var safeArea: UInt8 = 0
    }

    @available(iOS 11, *)
    public var safeArea: SafeAreaExtension {
        _ = UIView.swizzleSafeAreaInsetsDidChange
        guard let safeArea = objc_getAssociatedObject(self, &AssociatedKey.safeArea) as? SafeAreaExtension else {
            let safeArea = SafeAreaExtension(view: self)
            objc_setAssociatedObject(self, &AssociatedKey.safeArea, safeArea, .OBJC_ASSOCIATION_RETAIN)
            return safeArea
        }
        return safeArea
    }
}

UIView.safeAreaInsetsDidChange()をハンドリングできるよう、Method Swizzlingをします。
safeAreaInsetsDidChangesa_safeAreaInsetsDidChangeに置き換え元のメソッドを呼んだあとに、先程拡張したsafeArea.insetsDidChangeを呼び出し、現在のsafeAreaInsetsを渡します。

extension UIView {
    @available(iOS 11, *)
    fileprivate static var swizzleSafeAreaInsetsDidChange: () = {
        let originalSelector = #selector(UIView.safeAreaInsetsDidChange)
        let swizzledSelector = #selector(UIView.sa_safeAreaInsetsDidChange)
        guard
            let originalMethod = class_getInstanceMethod(UIView.self, originalSelector),
            let swizzledMethod = class_getInstanceMethod(UIView.self, swizzledSelector)
            else { return }

        let flag = class_addMethod(UIView.self,
                                   originalSelector,
                                   method_getImplementation(swizzledMethod),
                                   method_getTypeEncoding(swizzledMethod))
        if flag {
            class_replaceMethod(UIView.self,
                                swizzledSelector,
                                method_getImplementation(originalMethod),
                                method_getTypeEncoding(originalMethod))
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod)
        }
    }()

    @available(iOS 11, *)
    @objc private func sa_safeAreaInsetsDidChange() {
        self.sa_safeAreaInsetsDidChange()
        safeArea.insetsDidChange?(safeAreaInsets)
    }
}

黒魔術と呼ばれているAssociatedObjectやMethod Swizzlingを利用しているので、好みがわかれる部分かと思います。
こちらの実装はSafeAreaExtensionとしてGithubで公開しています。

RxSwiftを利用

下記のようにRxSwiftでUIViewを拡張して、safeAreaInsetsの変更を感知できるようにしようと思います。

class ViewController: UIViewController {
    ...
    override func viewDidLoad() {
        super.viewDidLoad()
        ...    
        view.rx.safeAreaInsetsDidChange()
            .subscribe(onNext: { insets in
                print("view.safeAreaInsets = \(insets)")
            })
            .disposed(by: disposeBag)
    }
}

実装

Reactiveを拡張し、methodInvokedを利用してsafeAreaInsetsDidChangeの呼び出しを監視しています。
メソッドが呼び出されたら、base.safeAreaInsetsをObservable<UIEdgeInsets>.just()しています。

extension Reactive where Base: UIView {
    func safeAreaInsetsDidChange() -> Observable<UIEdgeInsets> {
        guard #available (iOS 11, *) else { return .empty() }
        return base.rx.methodInvoked(#selector(Base.safeAreaInsetsDidChange))
            .flatMap { [weak base] _ -> Observable<UIEdgeInsets> in
                guard let base = base else { return .empty() }
                return .just(base.safeAreaInsets)
            }
    }
}

その他

コード上でAutoLayoutを利用して、Safe Areaに対応することもできます。
例として、任意のviewをsuperviewの上下左右に対してsafeAreaを考慮してaddSubviewする実装をしていきます。

NSLayoutConstraintを利用する場合は、view.safeAreaLayoutGuideをitemまたはtoItemに渡して制約を実装することができます。

let aView = UIView()
aView.translatesAutoresizingMaskIntoConstraints = false

let attributes: [NSLayoutAttribute] = [.top, .right, .left, .bottom]
let constraints = attributes.map {
    NSLayoutConstraint(item: view.safeAreaLayoutGuide,
                       attribute: $0,
                       relatedBy: .equal,
                       toItem: aView,
                       attribute: $0,
                       multiplier: 1,
                       constant: 0)
}
view.addSubview(aView)
view.addConstraints(constraints)

上記と同様の実装をNSLayoutAnchorを利用する場合は、下記のような実装になります。

let aView = UIView()
aView.translatesAutoresizingMaskIntoConstraints = false

view.addSubview(aView)
NSLayoutConstraint.activate([
    view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: aView.topAnchor),
    view.safeAreaLayoutGuide.leftAnchor.constraint(equalTo: aView.leftAnchor),
    view.safeAreaLayoutGuide.rightAnchor.constraint(equalTo: aView.rightAnchor),
    view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: aView.bottomAnchor)
])

MisterFusionというAutoLayoutのDSLでもSafe Areaに対応しています。

let aView = UIView()
view.mf.addSubView(aView, andConstraints: view.safeArea.top    |==| aView.top,
                                          view.safeArea.right  |==| aView.right,
                                          view.safeArea.left   |==| aView.left,
                                          view.safeArea.bottom |==| aView.bottom)