🪜

SwiftUI + PHPickerViewController + αで画像をcropする

2023/02/07に公開

ピッカーで選んだ画像のcrop

概要

SwiftUIで、画像を選択して、好きなサイズで切り取って、元のビューに返す(というか@Binding変数に入れる)、ということをしてみました。

Swift version 5.7.2
Xcode Version 14.2 (14C18)
iOS Simulator 16.2

サンプルプロジェクト全体はSwiftUIImageCropPickerという名前でGitHubにあげてあります

動作のサンプル動画は以下(iPhone)

YouTubeのvideoIDが不正ですhttps://youtube.com/shorts/J3lHTRRpFB4

動作のサンプル動画は以下(iPad)

YouTubeのvideoIDが不正ですhttps://youtube.com/shorts/jE_IZvi-HDU

画像ピッカー

元々Objective-Cで作っていたアプリのSwiftUIでの焼き直し、ということをやろうとしていて、そっちはUIImagePickerViewControllerを使っていたのですが、iOS14からはPHPickerViewControllerが推奨ということなので、そちらを使うようにしました。

SwiftUIではそれに相当するものがないので、PHPickerViewControllerUIViewControllerReprentableでラップして使います。
サンプルコード上はImageCropPickerが該当します。

CoordinatorPHPickerViewControllerDelegateです。選び終わったり、Cancelされたりするとpicker(:didFinishPicking:)が呼ばれます。

crop用のビュー

PHPickerViewControllerで選んだ画像をcropするためのビューは、SwiftUIで作りました。
サンプルコード上はImageCropViewが該当します。
そのままだとUIKit側からSwiftUIを呼び出せないので、UIHostingControllerでラップして呼び出します。

PHPickerViewControllerDelegatepicker(:didFinishPicking:)から、picker.present(:animated:completion:)を使って、モーダルダイアログ化します。

UIKitから表示されたSwiftUIビューを閉じる

UIKitからUIHostingControllerでホスティングされたSwiftUIビューなんですが、どうやって閉じたら良いのかという話があります。
SwiftUIには、ビューを自分で閉じる機能、例えばdismiss()のようなものがありません。
調べるといくつか方法があるようです。

  1. @Environment(\.dismiss)を使う
  2. SwiftUIのビューをホストするUIHostingViewControllerから閉じさせる

1の例は以下のようになります。

@Environment(\.dismiss) var dismiss
    
var body: some View {
   Button("閉じる", action: {
       dismiss()
   })
}

今回は2の方法を使っています。
詳細は以下で説明します。

crop用のビューからピッカーを閉じる

UIImagePickerViewControllerallowsEditing=trueにすると、子であるcrop用ビューで元画像の移動?を終えたら、親であるピッカーごと閉じる、というような動きをします。
(話は逸れますが、このallowsEditingは何のためにあるんだろうって機能ですね。中途半端、というのも憚れるほどのちょっとした機能しかない)
ともあれ、PHPickerViewControllerでも子ビューで処理が完了したら、親であるピッカーが閉じるような動作をさせたいところです。ちなみに子ビューでキャンセルされたら、親ピッカーに戻ってきて再選択できるような動きにしたいですね。

子のcrop用ビューを閉じるのは先に書いたようにいくつかやり方があります。
親のピッカーの閉じ方に関しては情報があまりなく、子ビューのUIHostingViewControllerviewDidDisappear(:)から閉じるようにしました。

実際に色々実装して動きを見てみたのですが、子のビューが閉じる(アニメーション)→親ビューが閉じる(アニメーション)という感じだと冗長に見え、同時に閉じるような見え方のほうが自然だったため、アニメーション制御が可能な方法を使う必要がありました。
子ビューの閉じ方のうち、最初のdismissハンドラはアニメーションの制御ができないようだったので2番目の方法にしました。
サンプルコードでは、子ビューがアニメーションなしで閉じて、親ビューがアニメーションありで閉じるようにしています。逆だと不自然な動きになります。

具体的な実装方法です。

子のcrop用ビューは構造体なので、何かに渡してしまったあと、外からその値を書き換える、というのが難しく(というのが私の勘違いかも知れませんが)、もしそうするなら@Bindingなり@ObservedObjectなりを使う必要があります。
つまり子ビュー自体のプロパティとして、子ビュー自体をホストするUIHostingViewControllerの参照を持たせる、というようなことがしにくいです。子ビューの初期化の時点では、それをホストするUIHostingViewControllerがまだ存在しません。

このため、子ビューにCoordinatorの参照を持たせて初期化してUIHostingViewControllerに渡し、CoordinatorにはそのあとからUIHostingViewControllerの参照を渡すようにしました。

UIHostingViewControllerは、viewDidDisappear(:)を実装するため派生クラスを作っています。
サンプルコード上はImageCropViewHostingControllerが該当します。

これにより、以下のような順で処理されます。

  1. crop用ビューの「Cancel」「Choose」からは、UIHostingViewController.dismiss(:)を呼び出す
  2. crop用ビューが閉じられるので、今度はUIHostingViewController.viewDidDisappear(:)が呼び出される
  3. UIHostingViewController.viewDidDisappear(:)から、親ピッカーのdismiss(:)を呼び出す

crop用の枠とドラッグ

crop用の枠、ドラッグ用のハンドル(円×8個)、crop範囲外のグレー網掛けは、すべてSwiftUIの.overlayモディファイアを使って描いてます。

crop用の枠のイメージ

また、.overlayの中に描く図形にドラッグハンドラを付けています。

最初は、ドラッグ用のハンドルにドラッグハンドラを付けようとしたのですが、ドラッグハンドラが効く領域は.offsetモディファイアで動かした場合には追随して来ず、判定領域が画像のど真ん中に留まってしまうようです。試しにドラッグ用のハンドルに.background(.yellow)を付けると、ドラッグハンドルの円の辺りではなく、画像の真ん中に黄色の円が生まれます。

このため、ドラッグハンドラは四角形の側につけました。
ただ、画像の縁につけたグレーの四角形だと、その四角形の外側(つまりハンドルの円の外側のほう)をタップしてもドラッグハンドラが起動されないため、ハンドルを囲む、一回り大きな四角形をもう1つ用意して、そちらにドラッグハンドラを付けました。
ハンドルを囲む四角形は見えないようにする必要があります(というか見えるとカッコ悪いのです)が、.opacity(0)とするとドラッグハンドラが起動されなくなってしまいました。このため、.opacity(0.0001)という苦し紛れの設定をしています。これで、見えないけどもドラッグハンドラが起動される、という状態になりました。

また、ドラッグ用ハンドルにドラッグハンドラをつけられるのであれば、ハンドルごとにハンドラを分けることで、どのハンドルがドラッグされているのか判断する必要はなく、それぞれのハンドルごとに更新すべき変数を更新すれば良いのですが、四角形の側にドラッグハンドラをつけると、どのドラッグ用ハンドルがドラッグされているのか判断する必要が出てきます。
そして、どこがドラッグ開始点でそれがどのドラッグハンドルなのか判断するために、四角形の大きさを知る必要があるのですが、bodyの中でGeometryReaderを使って@State変数を更新すると怒られてしまいます。
このためDispatchQueue.main.asyncを使って@State変数を更新するようにしました。

できていないこと

サンプルコードにコメントで書いていますが、PHPickerViewControllerは、選択を解除するための良いAPIがないように見えます。

deselectAssets(withIdentifiers:)というのが用意されてはいるものの、このidentifiersに渡す値が手に入らないように見えます(私の探し方の問題かも)。
picker(:didFinishPicking:)に渡ってくるresultsから取れるassetIdentifiernilでした。

このため、子のcrop用ビューでキャンセルされて、親ピッカーに戻った場合、まだ同じ画像が選択された状態が継続しています。もう一度同じ画像をcropし直そうとすると、選択を解除するタップをして、再度選択するタップをする、というようなことになります。

これは、選択解除のAPIがなければ仕方ないのかも知れません。

Discussion