- WWDC22 Recap in GoodpatchのLT用に製作したアプリです
- WWDC22で発表されたSwift Regex / Swift ChartsやSwiftUIの新機能を活用して制作したレシート読み取り型簡易家計簿アプリになってます
Xcode 14+ iOS/iPadOS 16+
- 試作版です。リポジトリ作者の扱ったレシート以外の形式には対応していない場合があります
- ベータ期間中の動作画面などの共有・ライセンスはAppleのNDA等のルールに従います
- レシート画像から情報を読み取れる
- それを記録ができる
- 記録がいい感じに見れる
これをとにかく実装を簡単に実装します
.photosPicker( isPresented: $isPresented, selection: $pickerItems, maxSelectionCount: 1, matching: .images, preferredItemEncoding: .automatic, photoLibrary: PHPhotoLibrary.shared() ) .onChange(of: pickerItems) { newValue in if let value = newValue.first { imageLoading = true Task { try await loadTransferable(from: value) await MainActor.run { imageLoading = false } } } }
- モディファイアとコンポーネントのふたつの使い方がある
- PhotosPickerは押すと選択用Viewを開くボタンができる
- 裏側はほぼPHPhotoPickerのままだと思われる
- 値はPhotosPickerItemとして返ってくるため変換する必要がある
Beta 1のシミュレータでは正常に使えない!
private func loadTransferable(from imageSelection: PhotosPickerItem?) async throws { do { if let data = try await imageSelection?.loadTransferable(type: Data.self) { if let uiImage = UIImage(data: data) { await MainActor.run { self.uiImage = uiImage } } } } catch { print("\(#function) | error: \(error)") } }
loadTransferableで変換できるのはData型のみ! Image型もTransferableに適合しているので渡せるが、変換はできない UIImage型はそもそもTransferableに適合していない
private func executeTextRecognizer() { guard let cgImage = uiImage?.cgImage else { processLoading = false return } let requestHandler = VNImageRequestHandler(cgImage: cgImage) let request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler) request.revision = VNRecognizeTextRequestRevision3 request.recognitionLanguages = ["ja", "en"] do { try requestHandler.perform([request]) } catch { processLoading = false print("Unable to perform the requests: \(error)") } } private func recognizeTextHandler(request: VNRequest, error: Error?) { guard let observations = request.results as? [VNRecognizedTextObservation] else { processLoading = false return } let recognizedStringsAndBox = observations.compactMap { observation -> (String, CGPoint)? in guard let string = observation.topCandidates(1).first?.string else { return nil } return (string, observation.boundingBox.origin) } processLoading = false let sortedStrings = recognizedStringsAndBox.sorted { lhr, rhr in return abs(rhr.1.x - lhr.1.x) <= 0.01 ? lhr.1.y <= rhr.1.y : lhr.1.x <= rhr.1.x } print("result \(sortedStrings)") presentedReceipt = [ReceiptData(contents: sortedStrings.map { 0ドル.0 })] }
- 専用ViewはUIKit向けだけど処理だけならこれでできる
- 処理は残念だながらasync/await未対応のため関数で
日本語認識にはRevision指定が必要! VNRecognizeTextRequestRevision3を指定し 言語にjaを設定しておくこと
認識結果は単語ごとの配列、並び順も曖昧 ソートしてあげると確実! なお、座標は縦がx軸
extension ReceiptData { func totalCost() -> Int { let pattern = Regex { ChoiceOf { "合言" "合計" "クレジット" } ZeroOrMore(.whitespace.inverted) ZeroOrMore(.whitespace) "\" Capture { Regex { ZeroOrMore(.digit) Optionally(",") OneOrMore(.digit) } } } if let match = entireString.firstMatch(of: pattern) { let (_, costString) = match.output return Int(String(costString.replacing(Regex { "," }, with: { _ in "" }))) ?? -1 } return -1 } }
- 圧倒的に直感的に書ける
- ちょっとした置換処理も正規表現使わなくてOK
- Captureで一致した結果の一部を個別に取れるように
認識結果の失敗や表記方法のブレを吸収する 「合計」は横長に伸びてると「合言」と認識されるとかを認識結果から観察しておく
文法を調べるのに岸川さんのサービスを使う! 動作確認にも使えるし、 正規表現の書き方検索して そこから変換することも
private var entries: [ChartEntry] { let sortedDatas = receiptDatas.sorted(by: { 0ドル.date < 1ドル.date }) let formatter = DateFormatter() formatter.dateFormat = "MMdd" return sortedDatas.map { ChartEntry(date: formatter.string(from: 0ドル.date), value: 0ドル.totalCost()) } } // ... Chart(entries, id: \.id) { entry in BarMark( x: .value("日付", entry.date), y: .value("値段", entry.value) ) .foregroundStyle(entry.color) } .frame(height: 300) .padding() // ... struct ChartEntry: Identifiable { let date: String let value: Int let color: Color = Color(white: .random(in: 0.2...0.8), opacity: 1.0) let id: UUID = UUID() }
- SwiftUI®への馴染み度No.1
- 簡単にグラフそれっぽくできちゃう度No.1
元の構造体とは別で専用構造体を用意する 色のコントロールとか同じ日付処理とかが 楽になる
- Xcode®が波括弧閉じたりすると自動フォーマットしてくれるのがすごい便利
- RawRepresentableに適合させると雑にAppStorageに突っ込める
- Sheet内のpush遷移先でdismissするためにDismissActionを受け渡す
- Previewも安定したけど仮データ作るの面倒で今回は使わなかった
- 認識結果にはやはりまだ限界がちょっとあった