見出し画像

続・iOSで英語手書き入力をやってみる

これは mikan Advent Calendar 2024 14日目の記事です。

皆様こんにちは。
mikanでiOSエンジニアをしているSabと申します。
mikanのAdvent Calender2024はお楽しみいただけておりますでしょうか?
ぜひコタツでmikanでもやりながらゆる〜くお楽しみいただければと思います。

昨日は@itoryoさんの「mikanが継続を重要視する理由と実際に取り組んでみて」でした。
普段PMがどんなことを考えて施策を立案しているのかが垣間見える内容でしたね👏
特にユーザーさんの継続に寄与する学習体験が楽しい!というポイントでは、エンジニアとしてもインタラクションのラグを極力減らして操作性を向上するなどで貢献していけるので一緒にゴリゴリ良くしていけたらと思いました😆
気になった方は是非一度読んでみてください!!!

さて、本日Day14はどんなことを書こうかな〜と考えていたのですが、昨年英単語を手書き入力できるiOS実装の記事を書いたことを思い出し、せっかくだからこのアプリをもう少しアプリらしくしよう!と思いつきました💡

昨年作った手書き入力アプリ

というわけで、mikanとは全然関係ないんですが本日は昨年実装した手書き入力アプリに1年越しに手を加えて、より実用的なものにしてみようと思います✨


何を作るか


昨年作った手書き入力アプリに以下の機能を追加します。

  • 英単語の日本語訳を表示し、それに対応する英単語を入力できる

  • 入力された英単語が、表示されている日本語訳が想定するものに一致しているかを判定する

  • 出題を開始する

  • 出題を中止する

こんなところでしょうか。
学習アプリとするなら一定数問題を解いたら成績画面を表示するとか、学習結果を永続化しておいて繰り返し学習に繋げたり、復習しやすくしたいところです。
さらに、学習効果を高めるために単語の発音を再生したり、インタラクティブにするため正誤判定の結果に合わせた効果音を鳴らしたりしたくなりますが、、、今回はスコープアウトします🙏

問題データを用意する


まずは問題を出題するにあたり元になるデータが欲しいです。
適当な英単語を自分で用意してもいいんですが、それだといかにも微妙なので今回はDiQtさんが公開している基礎英単語(NGSL)を使わせていただこうと思います。

NGSL(New General Service List)とは、頻出の基礎英単語です。

覚えることで、一般的な英文に含まれる単語の92%をカバーできるとされています。

DiQtさんのWebサイトより

とのことなので、覚えれば効果はばつぐんです⚡️
こんな素敵なリストをCC BY-SA 4.0ライセンスで公開してくださっているなんて太っ腹すぎて頭が上がりません🙇‍♂️

では、こちらのcsvリストをプロジェクトに組み込み&読み込みましょう。
まずはcsvファイルをコピーして、

csvから取り出す時の型を決めます。

struct Word {
  let english: String
  let japanese: String
  
  init?(_ row: [String: String]) {
    guard let english = row["entry"],
          let japanese = row["meaning"] else { return nil }
    self.english = english
    self.japanese = japanese
  }
}

あとはリポジトリを作成してそこで読み込むようにしてみます。
カンマでsplitしていけるかなーと思ったんですが、日本語訳にカンマが含まれているためちょっと面倒そうです。。。
こういう時は先人の知恵を拝借致しましょう🙏アリガトゴザイマス

ではこちらのライブラリを使って読み込んでいきます。

import SwiftCSV

struct WordsRepository {
  func getWords() async throws -> [Word] {
    guard let filePath = Bundle.main.path(forResource:"NGSL", ofType:"csv") else {
      throw WordsRepositoryError.fileNotFound
    }
    
    do {
      let csv = try CSV<Named>(url: URL(fileURLWithPath: filePath))
      return csv.rows.compactMap { Word($0) }
    } catch {
      throw WordsRepositoryError.fileReadFailer
    }
  }
}

enum WordsRepositoryError: Error {
  case fileNotFound
  case fileReadFailer
}

こんな感じでしょうか。
これでcsvファイルから英単語と日本語訳が取り出せるようになりました🎉

ロジックを作る


問題の準備ができたので、次は問題を読み込んで表示できるようにしたり、回答を受け付けて正誤判定できるようにしたいです。

ではまず読み込みから、、、
昨年の実装でViewModelを使っているので今年はそのまま進めます🙄

このあたりはいつかリファクタされる…かも??

final class ContentViewModel: ObservableObject {
...
  @Published var error: Error?

  private let wordsRepository: WordsRepository
  private var words: [Word] = []

  func loadWords() async {
    do {
      words = try await wordsRepository.getWords().shuffled()
    } catch {
      self.error = error
    }
  }
...
}

昨年の実装部分は省略していますが、ざっとこんな感じで先ほど作成したWordsRepositoryから問題を取得し、ランダムに並び替えるようにしました。
また、ちょっと雑ですが一旦エラーをキャッチした場合にViewに伝えるためのerrorプロパティを追加しています。

これで問題の読み込みができたので、次は出題部分です。

final class ContentViewModel: ObservableObject {
...
  var currentWord: Word { words[wordIndex] }

  private var wordIndex: Int = 0

  func nextWord() {
    if wordIndex >= words.count {
      wordIndex = 0
    } else {
      wordIndex += 1
    }
  }
...
}

現在出題中の単語が取得できるように、Indexから単語を返すようにしてIndexを操作することで次の単語を出題できる作りにしました。

次は正誤判定ですね。
これは今回文字列を小文字にして完全一致で判定していきましょう。

final class ContentViewModel: ObservableObject {
...
  private func checkAnswer(input: String) -> Bool {
    currentWord?.english.lowercased() == input.lowercased()
  }
...
}

いたってシンプルですね😅

Viewを作る


では最後にViewを作っていきます。
今回のところは画面遷移はなしとして、状態でViewを切り替える方針でいこうかなと思います。
その際のそれぞれの画面と機能は以下でしょうか。

  • 問題出題前の初期画面

    • 開始ボタンがある

  • 問題出題画面

    • 手書き入力ができる

    • 入力中のキャンバスをリセットできる

    • 回答確定ボタンがある

    • 中断ボタンがある

  • 正誤判定画面

    • 正誤判定結果がある

    • 次の問題へ進むボタンがある

    • 中断ボタンがある

  • エラー画面

    • リトライボタンがある

では順番に作っていきます💪

まずは昨年実装した手書き入力画面があるので、それをベースに問題出題画面を作ります。

   var body: some View {
    VStack {
      if let question = viewModel.currentWord?.japanese {
        Text(question)
          .font(.system(size: 16))
          .background(.white)
          .padding(.top, 40)
      }

      Spacer()

      Text(viewModel.displayText)
        .font(.system(size: 24, weight: .bold))
        .background(.white)
        .padding(8)
      
      Spacer()

      VStack(alignment: .leading) {
        Button {
          viewModel.didTapEnterButton()
        } label: {
          Text("回答を送信")
            .font(.system(size: 20, weight: .medium))
            .foregroundStyle(.white)
            .padding(.vertical, 4)
            .frame(maxWidth: .infinity)
        }
        .background(.orange)
        
        CanvasView(canvas: viewModel.canvasView)
          .frame(height: 100)
        
        Button {
          viewModel.didTapClearButton()
        } label: {
          Text("入力クリア")
            .font(.system(size: 14, weight: .medium))
            .foregroundStyle(.white)
            .padding(4)
            .background(.gray)
        }
      }
    }
    .overlay(alignment: .topTrailing) {
      Button {
        // TODO: タップアクション
      } label: {
        Text("中断する")
          .font(.system(size: 14, weight: .medium))
          .foregroundStyle(.white)
          .padding(6)
          .background(.gray)
      }
    }
    .padding()
    .background(Color(uiColor: .systemGray6))
  }

こんな感じでしょうか。

では状態の変化に合わせて画面を切り替えやすいように↑のViewを独立したViewとして定義して、次は初期画面を作っていきます。

  var body: some View {
    VStack {
      Spacer()
      
      Button {
        // TODO: タップアクション
      } label: {
        Text("学習スタート")
          .font(.system(size: 24, weight: .medium))
          .foregroundStyle(.white)
          .padding(12)
          .frame(maxWidth: .infinity)
          .background(.orange)
      }
      .padding(.bottom, 80)
    }
    .padding(.horizontal, 16)
    .background(Color(uiColor: .systemGray6))
  }

とってもシンプルな画面になりました。。。

お次はエラー画面です。
こちらも初期画面をベースとしたシンプル イズ ベストでいきましょう。

  var body: some View {
    VStack {
      Spacer()
      
      if let error = viewModel.error {
        Text("エラーです\n\(error.localizedDescription)")
      }
      
      Spacer()
      
      Button {
        Task {
          await viewModel.loadWords()
        }
      } label: {
        Text("リトライ")
          .font(.system(size: 24, weight: .medium))
          .foregroundStyle(.white)
          .padding(12)
          .frame(maxWidth: .infinity)
          .background(.orange)
      }
      .padding(.bottom, 80)
    }
    .padding(.horizontal, 16)
    .background(Color(uiColor: .systemGray6))
  }

良いですね!笑

では残った正誤判定画面ですが、これは最初の問題出題画面と共通にして、正誤判定表示だけを追加しようと思います。

そのために、先に画面のStateを持つようにしてみます。

enum ScreenState: Equatable {
  case initial
  case question
  case answered(isCorrect: Bool)
  case error(Error)
}

final class ContentViewModel: ObservableObject {
...
  @Published var screenState: ScreenState = .initial

...
}

こんな感じですね。
エラー画面作成時に定義したエラーをViewに伝えるプロパティはここでお役御免となったため削除してます👋

あとはこれを使って入力画面で正誤判定を表示するように書きかえます。

  var body: some View {
    VStack {
      if let question = viewModel.currentWord?.japanese {
        Text(question)
          .font(.system(size: 16))
          .background(.white)
          .padding(.top, 40)
      }

      Spacer()

      Text(viewModel.displayText)
        .font(.system(size: 24, weight: .bold))
        .background(.white)
        .padding(8)
      
      Spacer()

      VStack(alignment: .leading) {
        
// この辺りを書きかえたよ
        switch viewModel.screenState {
          case .answered(let isCorrect):
            Text(isCorrect ? "○ 大正解" : "× ざんねん...")
                .font(.system(size: 24, weight: .bold))
                .foregroundStyle(isCorrect ? .green : .red)
                .frame(maxWidth: .infinity)
                .padding(.bottom, 12)
          case .initial, .question, .error:
            EmptyView()
        }

        Button {
          viewModel.didTapEnterButton()
        } label: {
          Text("回答を送信")
            .font(.system(size: 20, weight: .medium))
            .foregroundStyle(.white)
            .padding(.vertical, 4)
            .frame(maxWidth: .infinity)
        }
        .background(.orange)
        
        CanvasView(canvas: viewModel.canvasView)
          .frame(height: 100)
        
        Button {
          viewModel.didTapClearButton()
        } label: {
          Text("入力クリア")
            .font(.system(size: 14, weight: .medium))
            .foregroundStyle(.white)
            .padding(4)
            .background(.gray)
        }
      }
    }
    .overlay(alignment: .topTrailing) {
      Button {
        // TODO: タップアクション
      } label: {
        Text("中断する")
          .font(.system(size: 14, weight: .medium))
          .foregroundStyle(.white)
          .padding(6)
          .background(.gray)
      }
    }
    .padding()
    .background(Color(uiColor: .systemGray6))
  }

するとこんな感じになりました。
良きですね!!

動作を追加する


では最後の仕上げとして一連の動作を追加してみようと思います。

まずはなにかと回答済みかどうかを判断したいのでプロパティを追加して…

enum ScreenState {
  case initial
  case question
  case answered(isCorrect: Bool)
  case error(Error)
  
  var isAnswered: Bool {
    switch self {
      case .answered: true
      default : false
    }
  }
}

ViewModelのscreenStateを使って画面を切り替えるようにします。

struct ContentView: View {
  @StateObject var viewModel = ContentViewModel(wordsRepository: .init())
  
  var body: some View {
      VStack {
        switch viewModel.screenState {
          case .initial:
            InitialView()
              .environmentObject(viewModel)
          case .question, .answered:
            QuestionView()
              .environmentObject(viewModel)
          case .error:
            ErrorView()
              .environmentObject(viewModel)
        }
      }
      .task {
        await viewModel.loadWords()
      }
  }
}

あとはViewModel側にも足りていなかったメソッドなどを追加して…

final class ContentViewModel: ObservableObject {
  let canvasView = PKCanvasView()
  @Published var screenState: ScreenState = .initial
  @Published var displayText: String = ""
  
  var currentWord: Word? {
    guard !words.isEmpty else { return nil }
    return words[wordIndex]
  }
  
  private let wordsRepository: WordsRepository
  private var words: [Word] = []
  @Published private var wordIndex: Int = 0
  
  init(wordsRepository: WordsRepository) {
    self.wordsRepository = wordsRepository
  }
  
  func loadWords() async {
    do {
      words = try await wordsRepository.getWords().shuffled()
    } catch {
      screenState = .error(error)
    }
  }
  
  func didTapStartButton() {
    screenState = .question
  }
  
  func didTapQuitButton() {
    screenState = .initial
    wordIndex = 0
    Task { await loadWords() }
  }
  
  func didTapEnterButton() {
    if screenState.isAnswered {
      nextWord()
    } else {
      analyzeInput()
    }
  }
  
  func didTapClearButton() {
    canvasView.drawing = PKDrawing()
  }
  
  private func checkAnswer(input: String) -> Bool {
    currentWord?.english == input
  }
  
  private func analyzeInput() {
    guard let inputImage = canvasView
      .drawing
      .image(from: canvasView.bounds, scale: 1)
      .fillTransparentPixels(with: .white)?
      .cgImage else { return }
    
    let request = VNRecognizeTextRequest { request, error in
      guard let results = request.results as? [VNRecognizedTextObservation],
            let resultText = results.flatMap({ $0.topCandidates(1) }).sorted(by: { $0.confidence > $1.confidence }).first else { return }

      self.displayText = resultText.string
      self.canvasView.drawing = PKDrawing()
      self.screenState = .answered(isCorrect: self.checkAnswer(input: resultText.string))
    }
    
    request.recognitionLevel = .accurate
    request.recognitionLanguages = ["en_US"]
    request.usesLanguageCorrection = false
    let imageRequestHandler = VNImageRequestHandler(cgImage: inputImage)
    do {
      try imageRequestHandler.perform([request])
    } catch {
      displayText = error.localizedDescription
    }
  }
  
  private func nextWord() {
    canvasView.drawing = PKDrawing()
    displayText = ""
    screenState = .question
    
    if wordIndex >= words.count {
      wordIndex = 0
    } else {
      wordIndex += 1
    }
  }
}

こんな感じになりました。
あとは不正解だった場合に答えを教えて欲しいなと思ったのでそれも追加します。

struct QuestionView: View {
...
            Text(isCorrect ? "○ 大正解" : "× ざんねん...\n正解は \(viewModel.currentWord?.english ?? "取得できません") でした")
...
}

できました✨

完成


最終的な成果物はこちらです🎊🎊🎊

いかがでしたでしょうか。
誰に何を届けたいのか分からない、ただやりたいことやっただけのアドカレになってしまいました。笑
だがそんなアドカレもきっとあっていいと、僕はそう思います😌

最後まで読んでくださりありがとうございました🙇‍♂️

PR


今回作成したアプリ、機能としては主要な英単語を手書きしながら覚えられるなんとも素晴らしいものですが、、、
昨日の@itoryoさんの記事にもあった「使いやすさ」が圧倒的に欠けているように感じます🙈
その理由は単純で、デザインが〇〇だからです😭
僕はデザインがあまり得意ではないようで…

そうそう、デザインといえばmikanには優秀なデザイナーさんがたくさんおりますが、そのチームをリードしていってくださる人材をまだまだ募集中です!!!

それでは、明日のmikanアドベントカレンダーはmikan古参メンバーにして我らがPM、りょーちんことRyo iidaさんです!
いったいどんな内容なのでしょうか…ワクワク

お楽しみに👍


いいなと思ったら応援しよう!