18
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

safeAreaInsetsの変更を感知する

Last updated at Posted at 2017-10-16

はじめに

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)
18
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?