Timers Tech Blog

グローバルな家族アプリFammを運営するTimers inc (タイマーズ) の公式Tech Blogです。弊社のエンジニアリングを支える記事を随時公開。エンジニア絶賛採用中!→ https://timers-inc.com/engineering

SwiftUIで端末の画像を表示する #SwiftUI #Combine #UIImagePickerController

f:id:fromkk:20191220181258p:plain

忘年会シーズンに胃腸炎になってしまい全然お酒が飲めないかっくん(@fromkk)です。
社内向けに簡単なツールを作成しようと思いSwiftUIとCombineで作ってみたのですが、端末内にある写真を取得・表示する必要があったのでSwiftUIからUIKitのクラスを呼び出して画像を表示する方法を模索してみました。

UIImagePickerController

UIImagePickerControllerは昔からよく利用されている端末の画像を読み込む為のクラスです。
写真を数枚程度選択するぐらいならこちらで十分かと思います。
今回はこちらを利用してみます。

SwiftUI

SwiftUIは今年のWWDCで発表され、Xcode 11から利用可能な新しいUIの仕組みです。
iOS/watchOS/tvOS/macOS/macCatalystで利用が可能です。
宣言的に記述が可能になりこれまでのiOSでのUIの作り方とは随分と異なるのですが、まだまだ痒い所に手が届かない所があるので、そういう部分はこれまで利用してきた手法も利用する必要があります。

UIViewControllerのまま利用

まずは慣れ親しんだUIViewControllerpresentして画像ピッカーを表示する手法を試してみました。

import UIKit
import Combine

final class ImagePicker: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate, ObservableObject {
    @Published var image: UIImage?

    func pick(on viewController: UIViewController) {
        let imagePickerViewController = UIImagePickerController()
        imagePickerViewController.sourceType = .photoLibrary
        imagePickerViewController.delegate = self
        viewController.present(imagePickerViewController, animated: true)
    }

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        defer {
            picker.dismiss(animated: true, completion: nil)
        }
        image = info[.originalImage] as? UIImage
    }

    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        picker.dismiss(animated: true, completion: nil)
    }
}

こんな感じで ImagePicker クラスを作成してみました。
続いてアプリ全体で参照される AppSettings というクラスを作成し、

import UIKit

final class AppSettings: ObservableObject {
    weak var containerViewController: UIViewController?
}

そしてImagePickAndDisplayViewを作成しました。

import SwiftUI
import Combine
import UIKit

struct ImagePickAndDisplayView: View {
    @EnvironmentObject var appSettings: AppSettings
    @ObservedObject var imagePicker = ImagePicker()

    var body: some View {
        VStack(alignment: .center, spacing: 16) {
            if imagePicker.image == nil {
                Rectangle()
                    .frame(width: 300, height: 300, alignment: .top)
            } else {
                Image(uiImage: imagePicker.image!)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: 300, height: 300, alignment: .top)
                    .clipped()
            }
            Button(action: {
                self.imageSelect()
            }) {
                Text("Image Select")
            }
        }
    }

    func imageSelect() {
        guard let viewController = appSettings.containerViewController else { return }
        imagePicker.pick(on: viewController)
    }
}

struct ImagePickAndDisplayView_Preview: PreviewProvider {
    static var previews: some View {
        return ImagePickAndDisplayView()
    }
}

f:id:fromkk:20191220165003p:plain

この様なUIが出来上がったので、SceneDelegate.swift

var window: UIWindow?
private let appSettings = AppSettings()

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    let contentView = ImagePickAndDisplayView()
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        let rootViewController = UIHostingController(rootView: contentView.environmentObject(appSettings))
        appSettings.containerViewController = rootViewController
        window.rootViewController = rootViewController
        self.window = window
        window.makeKeyAndVisible()
    }
}

と設定しました。
肝はcontentView.environmentObject(appSettings)の辺りでしょうか。これでImagePickAndDisplayView側で @EnvironmentObject var appSettings: AppSettingsと定義しているだけで勝手に値が注入されます。便利。
結果はこんな感じです。

f:id:fromkk:20191220164139g:plain

ただ、まだUIKitの手法に慣れすぎてSwiftUIっぽく無いなと感じたのでもう少しSwiftUIだけで表示する方法を模索してみます。

更にSwiftUIらしく

という事で UIImagePickerController の表示部分をSwiftUIだけで実装してみます。

import SwiftUI
import Combine
import UIKit

struct ImagePickerView: UIViewControllerRepresentable {
    @Binding var image: UIImage?

    final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
        @Binding var image: UIImage?

        init(image: Binding<UIImage?>) {
            _image = image
        }

        func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
            defer {
                picker.dismiss(animated: true)
            }

            guard let image = info[.originalImage] as? UIImage else {
                return
            }

            self.image = image
        }

        func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
            picker.dismiss(animated: true)
        }
    }

    func makeCoordinator() -> ImagePickerView.Coordinator {
        let coordinator = Coordinator(image: $image)
        return coordinator
    }

    func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePickerView>) -> UIImagePickerController {
        let imagePickerViewController = UIImagePickerController()
        imagePickerViewController.sourceType = .photoLibrary
        imagePickerViewController.delegate = context.coordinator
        return imagePickerViewController
    }

    func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePickerView>) {
        // Nothing todo
    }
}

肝はSwiftUI独自のCoordinatorという存在ですかね。
雑な理解だとSwiftUIの世界とUIKitの世界を繋げてくれる様な役割かと思います。
UIの表示部分は

import SwiftUI
import Combine
import UIKit

struct ImagePickAndDisplaySwiftUIView: View {
    @State var image: UIImage?
    @State var showImagePicker: Bool = false
    
    var body: some View {
        VStack(alignment: .center, spacing: 16) {
            if image == nil {
                Rectangle()
                    .frame(width: 300, height: 300, alignment: .top)
            } else {
                Image(uiImage: image!)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: 300, height: 300, alignment: .top)
                    .clipped()
            }
            Button(action: {
                self.showImagePicker = true
            }) {
                Text("Image Select")
            }
        }.sheet(isPresented: $showImagePicker) {
            ImagePickerView(image: self.$image)
        }
    }
}

struct ImagePickAndDisplaySwiftUIView_Preview: PreviewProvider {
    static var previews: some View {
        return ImagePickAndDisplaySwiftUIView()
    }
}

この様になりました。
結果の動きは先ほどと全く同じです。

この辺りを実装してみたものをhttps://github.com/fromkk/SwiftUI-Device-Imageに上げてみたので実際のコードが気になる方は参考にしてみてください。

まとめ

SwiftUIだけだと実装が難しい部分を既存の知識を利用して表現してみました。
方法によってはSwiftUIっぽく無い実装も可能ですが、あまり綺麗だとは言えないのでなるべくSwiftUIだけの世界に近い方法を目指していきたいですね。
更に良い方法などあれば教えて頂けるととても嬉しいです。

P.S.

少し凝ったViewを実装しようとすると

The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions

というエラーにとても悩まされました。1つ1つのコンポーネントをなるべく小さく作っていく癖が必要そうですね。

積極採用中!!

子育て家族アプリFammを運営するTimers inc.では、現在エンジニアを積極採用中!
急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください!
採用HP: http://timers-inc.com/engineerings

参考: ios - How to open the ImagePicker in SwiftUI? - Stack Overflow

Timersでは各職種を積極採用中!

急成長スタートアップで、最高のものづくりをしよう。

募集の詳細をみる