iPadOS 13.4のポインタに対応する

2020年3月にiPadOS 13.4がリリースされ、ポインタが導入されました。iPadにマウスやトラックパッドを接続（有線もしくは無線）して利用できます。

# 対応端末

すべてのiPad Proモデル
iPad Air 2以降
iPad（第5世代）以降
iPad mini 4以降

4~5年前のモデルでも対応しており、幅広い端末で利用できます。

# Magnetic Effect

ボタンの上にポインタを移動させると、ボタンに飛びつくようなアニメーションとともに、ポインタがボタンの形に変化します。またポインタが離れる際には、ボタンにしがみつくようなアニメーションがあり、これらは「[Magnetic Effect](https://developer.apple.com/design/human-interface-guidelines/ios/user-interaction/pointers#pointer-magnetism)」と呼ばれています。
![78849069-70661f00-7a4e-11ea-8e03-ada6cdf77bea.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243326/1336da71-5696-80c5-1b73-96c686c3ebbd.gif)
# エフェクトは3種類

ポインタがボタンの上に移動した時のエフェクトは3種類用意されています。

`Highlight:` ポインタがボタンの形に変わります。ツールバーのアイコンやメニューなどで使われています。
![78849152-9d1a3680-7a4e-11ea-9150-a4bc9296e97e.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243326/5ad3367f-ad39-d7d6-aa68-b81cf476ac44.gif)

`Lift:` ボタンが拡大され、ポインタは非表示になります。ホーム画面のアイコンで使われています。
![78849165-a3a8ae00-7a4e-11ea-839d-661a3a44ab22.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243326/8a85fe6a-c70e-7a1d-ca82-a52fdae951fb.gif)

`Hover:` オーバーレイ（もしくはアンダーレイ）が表示されます。他にもオプションでボタンを拡大したり、シャドーをつけたり、ポインタの形を変えたりする事も可能です。カスタマイズすることで様々なエフェクトを実装できます。
![78849173-aacfbc00-7a4e-11ea-93ad-8f3cae3cc417.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243326/3ff3e18c-03ef-2e1f-7ef3-fcf60e0554f8.gif)

# ポインタの形は自由

ポインタの形は`UIBezierPath`で表現できる形であれば対応しています。例えばKeynoteではテキストをリサイズする時にポインタの形が変わります。
![78849227-d2bf1f80-7a4e-11ea-989b-e476c3a26791.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243326/74e3c910-260b-74b9-8e67-093ffd6161ac.gif)

# UIPointerInteraction API

ポインタ対応には`UIPointerInteraction API`を使います。

ただし、UIKitでは`UIButton`、`UIBarButtonItem`、`UISegmentedControl`、`UIMenuController`などがすでにポインタに対応しています。例えばUIButtonをポインタに対応させるためには、以下のコードを実行します。

```swift
if #available(iOS 13.4, *) {
    button.isPointerInteractionEnabled = true
}
```

デフォルトでは`Highlight`エフェクトが適用されますが、別のエフェクトやポインタの形を適用させたい場合は`pointerStyleProvider`を使います。

```swift
if #available(iOS 13.4, *) {
    button.pointerStyleProvider = { button, effect, shape in
        if case let .roundedRect(frame, radius) = shape {
            // デフォルトよりもハイライトエリアを3ptsずつ広げる
            let rect = CGRect(x:frame.origin.x-3, 
                              y:frame.origin.y-3, 
                              width:frame.width+6, 
                              height:frame.height+6)
            return UIPointerStyle(effect: effect, shape: .roundedRect(rect, radius: radius))
        }
        return nil
    }
} 
```

その他のビューでポインタに対応するには、`UIPointerInteraction`を登録します。使い方はドラッグ＆ドロップを実装するための`UIDragInteraction`/`UIDropInteraction`や、コンテキストメニューを実装するための`UIContextMenuInteraction`と同様です。

```swift
class TitleView: UIView {

    private func setupUI() {
        ...
        if #available(iOS 13.4, *) {
            enablePointer()
        }
    }

    ....
}

@available(iOS 13.4, *)
extension TitleView: UIPointerInteractionDelegate {

    func enablePointer() {
        isUserInteractionEnabled = true
        addInteraction(UIPointerInteraction(delegate: self))
    }
    
    func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
        // ポインタが反応すべきframeを返す
        return UIPointerRegion(rect: rectForTitle())
    }

    private func rectForTitle() -> CGRect {
        // AttributedTextが表示されているrectを返す
        return ...
    }

    func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
        // ポインタのエフェクト（UIPointerEffect）や形（UIPointerShape）を返す
        let targetedPreview = UITargetedPreview(view: self)
        let effect: UIPointerEffect = .highlight(targetedPreview)
        let shape: UIPointerShape = .roundedRect(rectForTitle(), radius: UIPointerShape.defaultCornerRadius)
        let pointerStyle = UIPointerStyle(effect: effect, shape: shape)
        return pointerStyle
    }
}
```
実際の動きは以下のようになります。`UIView`でもちゃんとポインタが反応するようになりました。
![ezgif-6-a28da0dbee9e.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243326/66c4ee22-10bb-8f57-13b9-b49a93af5013.gif)

上記の例では`Highlight`エフェクトを利用していますが、例えば`Hover`エフェクトでオーバーレイを表示させたい場合は`pointerInteraction(_:styleFor:)`を次のように実装します。

```swift
@available(iOS 13.4, *)
extension TitleView: UIPointerInteractionDelegate {

    func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
        let targetedPreview = UITargetedPreview(view: self)
        targetedPreview.parameters.visiblePath = UIBezierPath(roundedRect: rectForTitle(), cornerRadius: 10.0)
        // Hoverエフェクトを利用する
        let effect: UIPointerEffect = .hover(targetedPreview, preferredTintMode: .overlay, prefersShadow: false, prefersScaledContent: false)
        let pointerStyle = UIPointerStyle(effect: effect)
        return pointerStyle
    }
}
```
動きはこのようになります。`Highlight`と違って、`Hover`ではポインタの形は変わらず、オーバーレイのみが表示されます。Magnetic Effectも適用されません。
![ezgif-6-a9d7c3f33eae.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243326/69496dba-06e8-38d6-17d6-e31f07fc1e83.gif)
もし`Highlight`, `Lift`, `Hover`以外のまったく違うエフェクトを適用したい場合は、`UIPointerInteractionDelegate`の`pointerInteraction(_:willEnter:animator:)`/`pointerInteraction(_:willExit:animator:)`を使って独自のアニメーションを実装できます。

```swift
@available(iOS 13.4, *)
extension TitleView: UIPointerInteractionDelegate {

    func pointerInteraction(_ interaction: UIPointerInteraction, willEnter region: UIPointerRegion, animator: UIPointerInteractionAnimating) {
        // ポインタがビューの上に移動した時に呼ばれる
        showEventResizingHandler(true)
    }

    func pointerInteraction(_ interaction: UIPointerInteraction, willExit region: UIPointerRegion, animator: UIPointerInteractionAnimating) {
        // ポインタがビューの外に移動した時に呼ばれる
        animator.addAnimations {
            self.showEventResizingHandler(false)
        }
    }
}
```
[拙作カレンダーアプリ](https://firstseed.io/jp/)では予定にポインタを移動させるとハンドラーが表示され、それをドラッグする事で予定の長さを変更する、という機能の実装に利用しています。
![ezgif-6-49bffc550953.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243326/488159af-7bf1-2059-626a-c182f9dc9c18.gif)
最後に、指とポインタによる入力を区別したい場合があります。例えば上記の「予定の端をドラッグして、予定の長さを変える」という機能ですが、ポインタを利用している時には便利な機能ですが、指を使って予定の端をドラッグするというのは至難の技です。また、そもそも指で操作している時にはリサイズのためのハンドラーが表示されないという問題もあります。

このような場合、`Info.plist`に`UIApplicationSupportsIndirectInputEvents`キーを追加することで、`UITouch.type`の値が指の場合は`UITouch.TouchType.direct`、ポインタの場合は`UITouch.TouchType.indirect`となり、指とポインタを区別する事ができます。

```swift
class CalendarView: UIView {

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        if #available(iOS 13.4, *), touches.first?.type == .some(.indirectPointer) {
            // ポインタによる操作
            ...
        } else {
            // 指による操作
            ...
        }
    }
}
```
ただしこのキーを追加すると、例えばカスタムの`UIGestureRecognizer`を実装している場合、`UIGestureRecognizer.numberOfTouches`が0になるケースがあり、その結果`UIGestureRecognizer.location(ofTouch:in:)`でクラッシュが発生するなどの副作用するため、アプリを再度テストする必要があります。詳しくはこちらの[ドキュメント](https://developer.apple.com/documentation/bundleresources/information_property_list/uiapplicationsupportsindirectinputevents)をご覧ください。

### 参考

- Pointer Interactions
https://developer.apple.com/documentation/uikit/pointer_interactions

- Human Interface Guideline: Pointers (iPadOS)
https://developer.apple.com/design/human-interface-guidelines/ios/user-interaction/pointers/

- UIApplicationSupportsIndirectInputEvents
https://developer.apple.com/documentation/bundleresources/information_property_list/uiapplicationsupportsindirectinputevents

- Supporting Pointer Interactions
https://pspdfkit.com/blog/2020/supporting-pointer-interactions/