safeAreaInsetsの変更を感知する

# はじめに

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

# safeAreaInsetsの変更を感知する方法

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

## UIViewのsubclass

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

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

### 実装

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

```swift
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](https://qiita-image-store.s3.amazonaws.com/0/60325/92791ee9-4ae0-5aa7-dcd5-ac6953ff3194.png)

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

## UIViewを拡張

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

```swift
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 }`になります。

```swift
// 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としています。

```swift
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をします。
`safeAreaInsetsDidChange`を`sa_safeAreaInsetsDidChange`に置き換え元のメソッドを呼んだあとに、先程拡張した`safeArea.insetsDidChange`を呼び出し、現在のsafeAreaInsetsを渡します。

```swift
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](https://github.com/marty-suzuki/SafeAreaExtension)としてGithubで公開しています。

## RxSwiftを利用

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

```swift
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()`しています。

```swift
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に渡して制約を実装することができます。

```swift
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を利用する場合は、下記のような実装になります。

```swift
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](https://github.com/marty-suzuki/MisterFusion)というAutoLayoutのDSLでもSafe Areaに対応しています。

```swift
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)
```