はじめに
こんにちは、最近NewJeans沼にどっぷりハマっている永田です。
Cool With You, Cookieが特にお気に入りで、Coke STUDIO SUPERPOP JAPAN 2023も参戦予定でとても楽しみです。
さて、今回はLiitというアプリをコピーしてみた中で学んだことを書き記そうと思います。
開発環境
本記事で扱うコードは以下の環境で動作することを想定しています。
- Xcode 14.3.1
- Swift 5.8.1
- iOS 16.0+
Liit とは
Liitとは写真編集アプリの一種です。
さまざまなフィルターやフレームなどが用意されており、簡単に写真を編集できます。
その中でも写真のメタデータ(EXIF)を表示できるフレームがX(ex-Twitter)の写真家たちの間で流行っていました。
普段画像データをこねくり回すようなコードを書くことがないので、写真からEXIF情報を取得する方法を知らずかなり興味を惹かれました。
その方法さえわかれば自作できるのではと考え、勉強がてらLiitをコピーしてみることにしました。
実装する機能
まずは実装する機能の洗い出しを行いました。
ざっくり以下の5つに分けて実装を行います。
- 写真ライブラリから写真を選択する
- 写真からEXIFをパースする
- 写真とEXIFをフレームに入れて表示する
- フレームを画像化する
- 画像をシェアする
これらのそれぞれについて、簡単なサンプルコードと共に実装方法をご紹介します。
実装の流れ
写真ライブラリから写真を選択する
ここでは PhotosPickerを使用しました。
iOS 16以降で使えるSwiftUIの Viewで、UIKitにある PHPickerViewControllerや UIImagePickerControllerと同等の機能を持っています。
以下のように Viewの body内に配置することで、写真ピッカーを表示させるボタンを表示できます。
import PhotosUI import SwiftUI struct ContentView: View { @State private var pickedPhoto: PhotosPickerItem? var body: some View { PhotosPicker( selection: $pickedPhoto, matching: .images, photoLibrary: .shared() ) { Text("Select a photo") } } } |
写真からEXIFをパースする
ここでは CIImage.properties を使用しました。
これは写真のメタデータを格納する [String: Any]のディクショナリで、使用できるキーはImageIOのEXIF Dictionary Keysに定義されています。
以下は、
PhotosPickerで取得した画像から「焦点距離」を取得する例です。
特定のキーを用いて
propertiesから値を取り出しています。
func parseFocalLength(from pickedPhoto: PhotosPickerItem) async -> Int? { guard let imageData = try? await pickedPhoto.loadTransferable(type: Data.self), let properties = CIImage(data: imageData)?.properties, let exif = properties[kCGImagePropertyExifDictionary as String] as? [String: Any], let focalLength = exif[kCGImagePropertyExifFocalLength as String] as? Int else { return nil } return focalLength } |
ただし、このメタデータをパースする処理にはしんどいポイントが何箇所もあります。たとえば、
- キーごとに取得できる値の型がドキュメントに書かれていない
- キーによってはディクショナリが入れ子になっている場合がある
- 入れ子になっているディクショナリに対してしか使えないキーも多く存在する
- ↑のようなキーたちが、どのキーから取得したディクショナリに対して使うものなのかがドキュメントに書かれていない
などなど、自力で色々調査しながら実装する必要があり、かなりめんどくさいです。
楽曲のメタデータもそうですが、このあたりのドキュメントが適当すぎるのはどうにかならないものか。。。
自分は、パースのロジックを ImageMetadataParserというクラスに集約させ使用することにしました(具体的な実装は本記事の末尾に記載しています)。
写真とEXIFをフレームに入れて表示する
ここではCombineを使用したMVVMパターンで実装をしています。
ObservableObjectなViewModelを作成し、
pickedPhotoが変わるたびにEXIFをパースしています。
パースした結果のEXIFデータを
@Publishedで定義しておき、その変更をViewで監視しています。
import Combine import PhotosUI import SwiftUI @MainActor final class ContentViewModel: ObservableObject { @Published var pickedPhoto: PhotosPickerItem? @Published var showFocalLengthIn35mmFilm = false @Published private(set) var exif: ExifData? private var cancellables = Set<AnyCancellable>() init() { $pickedPhoto .receive(on: DispatchQueue.main) .sink { _ in Task { @MainActor in await self.parse() } } .store(in: &cancellables) } private func parse() async { guard let imageData = try? await pickedPhoto?.loadTransferable(type: Data.self), let parser = ImageMetadataParser(data: imageData) else { return } exif = .init( imageData: imageData, cameraMaker: parser.parse(for: \.cameraMaker), cameraModel: parser.parse(for: \.cameraModel), lensModel: parser.parse(for: \.lensModel), focalLength: parser.parse(for: \.focalLength), focalLengthIn35mmFilm: parser.parse(for: \.focalLengthIn35mmFilm), fNumber: parser.parse(for: \.fNumber), exposureTime: parser.parse(for: \.exposureTime).map(Fraction.init(number:)), iso: parser.parse(for: \.isoSpeedRatings)?.first ) } } |
フレームを画像化する
ここでは ImageRendererを使用しました。
iOS 16以降で使えるSwiftUIのAPIで、SwiftUIの Viewを画像化できます。
このような extensionを定義しておくことで、任意の Viewの snapshot()メソッドを呼ぶことで簡単に画像を取得できます。
extension View { @MainActor func snapshot(scale: CGFloat) -> UIImage? { let renderer = ImageRenderer(content: self) renderer.scale = scale return renderer.uiImage } } |
画像をシェアする
ここでは ShareLinkを使用しました。
iOS 16以降で使えるSwiftUIの Viewで、UIKitにあった UIActivityViewControllerと同等の機能を持っています。
以下のように Viewの body内に配置することで、シェアシートを表示するボタンを表示できます。
import SwiftUI struct ContentView: View { // 中略 var body: some View { // 中略 ShareLink( "画像をシェアする", item: image, preview: .init( "Share ExiFrame Image", image: image ) ) } } |
完成
これらの技術を組み合わせて、LiitのようにEXIF情報を表示した画像をシェアできるアプリが完成しました。
完成品のコードは以下に記載しておきます。
UIKitと比べてかなり少ないコード量で実装できている印象を受けました。
ContentViewModel.swift
import Combine import PhotosUI import SwiftUI @MainActor final class ContentViewModel: ObservableObject { @Published var pickedPhoto: PhotosPickerItem? @Published var showFocalLengthIn35mmFilm = false @Published private(set) var exif: ExifData? private var cancellables = Set<AnyCancellable>() init() { $pickedPhoto .receive(on: DispatchQueue.main) .sink { _ in Task { @MainActor in await self.parse() } } .store(in: &cancellables) } private func parse() async { guard let imageData = try? await pickedPhoto?.loadTransferable(type: Data.self), let parser = ImageMetadataParser(data: imageData) else { return } exif = .init( imageData: imageData, cameraMaker: parser.parse(for: \.cameraMaker), cameraModel: parser.parse(for: \.cameraModel), lensModel: parser.parse(for: \.lensModel), focalLength: parser.parse(for: \.focalLength), focalLengthIn35mmFilm: parser.parse(for: \.focalLengthIn35mmFilm), fNumber: parser.parse(for: \.fNumber), exposureTime: parser.parse(for: \.exposureTime).map(Fraction.init(number:)), iso: parser.parse(for: \.isoSpeedRatings)?.first ) } } |
ContentView.swift
import Combine import PhotosUI import SwiftUI struct ContentView: View { @StateObject private var viewModel = ContentViewModel() @Environment(\.displayScale) private var displayScale private var exifImage: ExifImage { .init( exif: viewModel.exif, showFocalLengthIn35mmFilm: viewModel.showFocalLengthIn35mmFilm ) } var body: some View { GeometryReader { geometry in VStack { exifImage PhotosPicker( selection: $viewModel.pickedPhoto, matching: .images, photoLibrary: .shared() ) { Text("Select a photo") } Toggle( "35mm換算する", isOn: $viewModel.showFocalLengthIn35mmFilm ) if let image = exifImage .frame(width: geometry.size.width) .snapshot(scale: displayScale) .map(Image.init(uiImage:)) { ShareLink( "画像をシェアする", item: image, preview: .init( "Share ExiFrame Image", image: image ) ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() } } } extension View { @MainActor func snapshot(scale: CGFloat) -> UIImage? { let renderer = ImageRenderer(content: self) renderer.scale = scale return renderer.uiImage } } |
ExifImage.swift
import SwiftUI struct ExifImage: View { private var exif: ExifData? private var showFocalLengthIn35mmFilm: Bool private let margin = CGFloat(16) init( exif: ExifData?, showFocalLengthIn35mmFilm: Bool ) { self.exif = exif self.showFocalLengthIn35mmFilm = showFocalLengthIn35mmFilm } var body: some View { Group { VStack(spacing: 4) { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) .padding(.bottom, margin) Group { HStack { Text(cameraMaker) Text(cameraModel) } .foregroundColor(Color.black) Text(lensModel) .foregroundColor(Color.gray) } HStack { Text(focalLength) Text(fNumber) Text(shutterSpeed) Text(iso) } .foregroundColor(Color.black) } .bold() .padding(margin) } .background(Color.white) } } private extension ExifImage { var image: UIImage { exif?.imageData.flatMap(UIImage.init(data:)) ?? .filled() } var cameraMaker: String { exif?.cameraMaker ?? "Unknown Maker" } var cameraModel: String { exif?.cameraModel ?? "Unknown Camera" } var lensModel: String { exif?.lensModel ?? "Unknown Lens" } var focalLength: String { let focalLength = { if showFocalLengthIn35mmFilm { return exif?.focalLengthIn35mmFilm ?? .zero } else { return exif?.focalLength ?? .zero } }() return "\(focalLength)mm" } var fNumber: String { "f/" + .init(format: "%.1f", exif?.fNumber ?? .zero) } var shutterSpeed: String { "\(exif?.exposureTime?.fractionalExpression ?? "0")s" } var iso: String { "ISO\(exif?.iso ?? .zero)" } } extension UIImage { static func filled(with color: UIColor = .black) -> UIImage { let rect = CGRect( origin: .zero, size: .init(width: 1, height: 1) ) return UIGraphicsImageRenderer(size: rect.size) .image { $0.cgContext.setFillColor(color.cgColor) $0.fill(rect) } } } |
ImageMetadataParser.swift
import CoreImage struct ImageMetadataParser { private let imageMetadataKeys = ImageMetadataKeys.shared private let properties: [String: Any] init?(data: Data) { guard let properties = CIImage(data: data)?.properties else { return nil } self.properties = properties } func parse<T>(for keyPath: KeyPath<ImageMetadataKeys, ImageMetadataKey<T>>) -> T? { let key = imageMetadataKeys[keyPath: keyPath] let targetDictionary: [String: Any] = { guard let parentDictionaryKey = key.parentDictionaryKey else { return properties } return parse(from: properties, for: parentDictionaryKey.keyName) ?? .init() }() return parse(from: targetDictionary, for: key.keyName) } private func parse<T>( from dictionary: [String: Any], for keyName: CFString ) -> T? { dictionary[keyName as String] as? T } } extension ImageMetadataParser { struct ImageMetadataDictionaryKey { let keyName: CFString } struct ImageMetadataKey<T> { let keyName: CFString let parentDictionaryKey: ImageMetadataDictionaryKey? } struct ImageMetadataKeys { static let shared = Self() private init() { } private enum MetadataDictionary { static let exif = ImageMetadataDictionaryKey(keyName: kCGImagePropertyExifDictionary) static let auxiliaryExif = ImageMetadataDictionaryKey(keyName: kCGImagePropertyExifAuxDictionary) static let tiff = ImageMetadataDictionaryKey(keyName: kCGImagePropertyTIFFDictionary) } // MARK: Lens Information let lensMaker = ImageMetadataKey<String>( keyName: kCGImagePropertyExifLensMake, parentDictionaryKey: MetadataDictionary.exif ) let lensModel = ImageMetadataKey<String>( keyName: kCGImagePropertyExifLensModel, parentDictionaryKey: MetadataDictionary.exif ) // MARK: Camera Information let cameraMaker = ImageMetadataKey<String>( keyName: kCGImagePropertyTIFFMake, parentDictionaryKey: MetadataDictionary.tiff ) let cameraModel = ImageMetadataKey<String>( keyName: kCGImagePropertyTIFFModel, parentDictionaryKey: MetadataDictionary.tiff ) // MARK: Camera Settings /// 焦点距離 let focalLength = ImageMetadataKey<Int>( keyName: kCGImagePropertyExifFocalLength, parentDictionaryKey: MetadataDictionary.exif ) /// 焦点距離(35mm換算) let focalLengthIn35mmFilm = ImageMetadataKey<Int>( keyName: kCGImagePropertyExifFocalLenIn35mmFilm, parentDictionaryKey: MetadataDictionary.exif ) /// F値 let fNumber = ImageMetadataKey<Double>( keyName: kCGImagePropertyExifFNumber, parentDictionaryKey: MetadataDictionary.exif ) /// シャッタースピード let shutterSpeed = ImageMetadataKey<Double>( keyName: kCGImagePropertyExifShutterSpeedValue, parentDictionaryKey: MetadataDictionary.exif ) /// 露光時間 let exposureTime = ImageMetadataKey<Double>( keyName: kCGImagePropertyExifExposureTime, parentDictionaryKey: MetadataDictionary.exif ) /// ISO感度 let isoSpeedRatings = ImageMetadataKey<[Int]>( keyName: kCGImagePropertyExifISOSpeedRatings, parentDictionaryKey: MetadataDictionary.exif ) } } |
Data.swift
struct ExifData { let imageData: Data? let cameraMaker: String? let cameraModel: String? let lensModel: String? let focalLength: Int? let focalLengthIn35mmFilm: Int? let fNumber: Double? let exposureTime: Fraction? let iso: Int? } /// 分数を表現する構造体 struct Fraction { /// 分子 let numerator: Int /// 分母 let denominator: Int var fractionalExpression: String { "\(numerator)/\(denominator)" } } extension Fraction { // NOTE: https://stackoverflow.com/questions/35895154/decimal-to-fraction-conversion-in-swift init(number: Double) { let precision = 1.0E-6 var x = number var a = x.rounded(.down) var (h1, k1, h, k) = (1, 0, Int(a), 1) while x - a > precision * Double(k) * Double(k) { x = 1.0/(x - a) a = x.rounded(.down) (h1, k1, h, k) = (h, k, h1 + Int(a) * h, k1 + Int(a) * k) } self.init(numerator: h, denominator: k) } } |
終わりに
今回は以下の技術を用いてLiitのコピーアプリを作ってみました。
- SwiftUI
- PhotosPicker
- ImageRenderer
- ShareLink
- Combine
- CoreImage
- CIImage
- ImageIO
- EXIF Dictionary Keys
画像データをこねくりまわすなど、普段の業務では関わりの少ない分野に触れられたのが新鮮で楽しかったです。
SwiftUIの新しいAPIにもいくつか触れることができ、SwiftUIもここ数年でかなり便利になってきていることを実感できました。
OSのサポートラインの問題はどうしてもついてまわりますが、新規のアプリ等では積極的に導入していきたいと思います。
また気になるアプリが出てきたらコピーしながら勉強しようかと思います。
参考文献
- PhotosPicker | Apple Developer Documentation
- properties | Apple Developer Documentation
- EXIF Dictionary Keys | Apple Developer Documentation
- ImageRenderer | Apple Developer Documentation
- ShareLink | Apple Developer Documentation
- [SwiftUI]iOS 16から使えるPhotosPickerの使い方
- 写真データからEXIFデータを取り出す方法 | SmallDeskSoftware
- Swiftで画像のExifデータから作成日時の情報を得る – Qiita
- How to convert a SwiftUI view to an image – a free SwiftUI by Example tutorial
- 【SwiftUI】iOS16.0 以降で使える ShareLinkでできることを調べてみた – Qiita
この記事を書いた人
-
iOSアプリを作っています
音楽とガジェットが好きです
最近書いた記事
- 2023.12.11【iOS】Kotlin MultiplatformでApp Clip対応アプリを2か月で作ったので振り返る
- 2023.09.01【iOS】SwiftUIとCoreImageを使って、EXIF情報を表示した画像をシェアする
- 2022.12.24Swift 5.7の新機能を用いて、複数サービスを扱うアプリをリアーキテクチャする
- 2021.05.31SwiftUIに出てくるsomeとは何なのか