SwiftUI + PHPickerViewController + αで画像をcropする
ピッカーで選んだ画像のcrop
概要
SwiftUIで、画像を選択して、好きなサイズで切り取って、元のビューに返す(というか@Binding変数に入れる)、ということをしてみました。
Swift version 5.7.2
Xcode Version 14.2 (14C18)
iOS Simulator 16.2
サンプルプロジェクト全体はSwiftUIImageCropPickerという名前でGitHubにあげてあります。
動作のサンプル動画は以下(iPhone)
YouTubeのvideoIDが不正です
動作のサンプル動画は以下(iPad)
YouTubeのvideoIDが不正です
画像ピッカー
元々Objective-Cで作っていたアプリのSwiftUIでの焼き直し、ということをやろうとしていて、そっちはUIImagePickerViewControllerを使っていたのですが、iOS14からはPHPickerViewControllerが推奨ということなので、そちらを使うようにしました。
SwiftUIではそれに相当するものがないので、PHPickerViewControllerをUIViewControllerReprentableでラップして使います。
サンプルコード上はImageCropPickerが該当します。
CoordinatorはPHPickerViewControllerDelegateです。選び終わったり、Cancelされたりするとpicker(:didFinishPicking:)が呼ばれます。
crop用のビュー
PHPickerViewControllerで選んだ画像をcropするためのビューは、SwiftUIで作りました。
サンプルコード上はImageCropViewが該当します。
そのままだとUIKit側からSwiftUIを呼び出せないので、UIHostingControllerでラップして呼び出します。
PHPickerViewControllerDelegateのpicker(:didFinishPicking:)から、picker.present(:animated:completion:)を使って、モーダルダイアログ化します。
UIKitから表示されたSwiftUIビューを閉じる
UIKitからUIHostingControllerでホスティングされたSwiftUIビューなんですが、どうやって閉じたら良いのかという話があります。
SwiftUIには、ビューを自分で閉じる機能、例えばdismiss()のようなものがありません。
調べるといくつか方法があるようです。
-
@Environment(\.dismiss)を使う - SwiftUIのビューをホストする
UIHostingViewControllerから閉じさせる
1の例は以下のようになります。
@Environment(\.dismiss) var dismiss
var body: some View {
Button("閉じる", action: {
dismiss()
})
}
今回は2の方法を使っています。
詳細は以下で説明します。
crop用のビューからピッカーを閉じる
UIImagePickerViewControllerでallowsEditing=trueにすると、子であるcrop用ビューで元画像の移動?を終えたら、親であるピッカーごと閉じる、というような動きをします。
(話は逸れますが、このallowsEditingは何のためにあるんだろうって機能ですね。中途半端、というのも憚れるほどのちょっとした機能しかない)
ともあれ、PHPickerViewControllerでも子ビューで処理が完了したら、親であるピッカーが閉じるような動作をさせたいところです。ちなみに子ビューでキャンセルされたら、親ピッカーに戻ってきて再選択できるような動きにしたいですね。
子のcrop用ビューを閉じるのは先に書いたようにいくつかやり方があります。
親のピッカーの閉じ方に関しては情報があまりなく、子ビューのUIHostingViewControllerのviewDidDisappear(:)から閉じるようにしました。
実際に色々実装して動きを見てみたのですが、子のビューが閉じる(アニメーション)→親ビューが閉じる(アニメーション)という感じだと冗長に見え、同時に閉じるような見え方のほうが自然だったため、アニメーション制御が可能な方法を使う必要がありました。
子ビューの閉じ方のうち、最初のdismissハンドラはアニメーションの制御ができないようだったので2番目の方法にしました。
サンプルコードでは、子ビューがアニメーションなしで閉じて、親ビューがアニメーションありで閉じるようにしています。逆だと不自然な動きになります。
具体的な実装方法です。
子のcrop用ビューは構造体なので、何かに渡してしまったあと、外からその値を書き換える、というのが難しく(というのが私の勘違いかも知れませんが)、もしそうするなら@Bindingなり@ObservedObjectなりを使う必要があります。
つまり子ビュー自体のプロパティとして、子ビュー自体をホストするUIHostingViewControllerの参照を持たせる、というようなことがしにくいです。子ビューの初期化の時点では、それをホストするUIHostingViewControllerがまだ存在しません。
このため、子ビューにCoordinatorの参照を持たせて初期化してUIHostingViewControllerに渡し、CoordinatorにはそのあとからUIHostingViewControllerの参照を渡すようにしました。
UIHostingViewControllerは、viewDidDisappear(:)を実装するため派生クラスを作っています。
サンプルコード上はImageCropViewHostingControllerが該当します。
これにより、以下のような順で処理されます。
- crop用ビューの「Cancel」「Choose」からは、
UIHostingViewController.dismiss(:)を呼び出す - crop用ビューが閉じられるので、今度は
UIHostingViewController.viewDidDisappear(:)が呼び出される -
UIHostingViewController.viewDidDisappear(:)から、親ピッカーのdismiss(:)を呼び出す
crop用の枠とドラッグ
crop用の枠、ドラッグ用のハンドル(円×8個)、crop範囲外のグレー網掛けは、すべてSwiftUIの.overlayモディファイアを使って描いてます。

また、.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から取れるassetIdentifierはnilでした。
このため、子のcrop用ビューでキャンセルされて、親ピッカーに戻った場合、まだ同じ画像が選択された状態が継続しています。もう一度同じ画像をcropし直そうとすると、選択を解除するタップをして、再度選択するタップをする、というようなことになります。
これは、選択解除のAPIがなければ仕方ないのかも知れません。
Discussion