🦋

SwiftUI: iOS標準の電卓っぽいPickerを作った

に公開


CalcPickerのデモ

https://github.com/kyome22/CalcPicker

プログラマーなら1度は電卓をちゃんと実装してみた方が経験として良さそうだよねということで、四則演算+剰余演算ができる電卓をどこからでも召喚できるPickerを実装してみました。

結果としてはかなり学びが多かったです。

iOS標準の電卓の仕様すげぇ

iOS標準の電卓には簡易電卓モード(基本)と関数電卓モード(科学計算)と計算メモモードが存在しています。今回は簡易電卓モードについてのみじっくり観察しました。電卓って一見簡単そうに見えるのですがなかなか厄介なポイントがたくさんあることに気づきました。

  • マイナス記号は演算子符号の2つの振る舞いを持つ
    • 乗算(×)や除算(÷)の後にマイナスは入力可能
    • 演算する際に負の数として扱うかどうかがマイナス記号より左側の数式コンテキストで変化する
  • ピリオド(小数点)を入力した際の挙動
    • 0の後ならそのまま入力:00.
    • 演算子の後なら0を挿入した上で入力:××0.
    • ピリオドの後なら何もしない:0.0.
    • すでに小数になっているなら何もしない:0.50.5
  • パーセント記号は剰余算百分率の2つの振る舞いを持つ
    • 数値と数値の間にパーセント記号がある時は剰余算:10%3
      • なお、負の数の時も考慮しなければならない
      • 割られる数が正の数なのか負の数なのかで余りの解釈の仕方が異なる
    • 末尾である、あるいは後ろに演算子が来る場合は百聞率:3% or 15%×3
  • プラスマイナスボタン(+/-)を入力した際の挙動
    • 最後の項が数値であるとき、正の数ならマイナス記号を挿入し、負の数ならマイナス記号を抜く
    • マイナス記号を挿入した結果演算子が連続する場合(+- or ×- or ÷-
      • +-なら+-で上書きする:5+35-3
      • ×-÷-なら数値を丸括弧で囲う:5×35×(-3)
  • ACボタンまたは削除ボタンの表示タイミング
    • 初期状態はACボタン
    • 数式入力中は削除ボタン
    • 演算結果表示中はACボタン
    • 演算結果に対して継続して数式を入力した場合は削除ボタン
  • 数式の長さが表示領域の横幅より長くなった場合左右にフェードが入る

今回実装した簡易電卓では上記の仕様を全て満たすと大変すぎたので、パーセント記号は剰余算だけに絞りました。

実装ポイント

数式のハンドリング方法

数式のハンドリング方法としては、数式を文字列として扱ってパースして意味解釈する方法と、数式を演算子と数値の項の配列としてみなして型安全にしておいて配列の意味解釈をする方法の2つ案がありました。Swiftの強みを活かすために後者を選び、enumで型を作ることでswitch文でのcaseの網羅性を活用しています。

演算方法

演算による誤差をなるべく無くしたかったのでDecimalを使用しています(完全に誤差をなくせる訳ではないので注意)。ただ、Decimalは演算に便利なAPIが全然生えていないので、自前で環境を整備しないといけませんでした。例えば四則演算に関してはDecimal * Decimalのように演算子を普通に使えば良いのですが、剰余演算子(%)には対応していないので自前で余りの計算をしなければなりません。今回は簡単のためNSDecimalNumberを経由してDoubleに変換して演算した後にDecimalに戻しています。

数式の表示UIをリッチに

詳しくは下の記事を参照してください。
https://zenn.dev/kyome/articles/d997a15860f3f9

テスト拡充

電卓のボタンを押した時のハンドリングについても、演算についても非常に複雑なロジックを組むことになるため、Unitテストを大量に書きながら仕様を固めつつ実装を進めました。また、Swift TestingのParameterized Testsの仕組みを活用してテストコードの実装量とテストケースの数のコスパを向上できました。

例えばこんな感じ
@testable import CalcPicker
import Testing

struct DigitTests {
    @Test(arguments: [
        .init(charactor: ".", expectedDigit: .period),
        .init(charactor: "0", expectedDigit: .number(0)),
        .init(charactor: "1", expectedDigit: .number(1)),
        .init(charactor: "2", expectedDigit: .number(2)),
        .init(charactor: "3", expectedDigit: .number(3)),
        .init(charactor: "4", expectedDigit: .number(4)),
        .init(charactor: "5", expectedDigit: .number(5)),
        .init(charactor: "6", expectedDigit: .number(6)),
        .init(charactor: "7", expectedDigit: .number(7)),
        .init(charactor: "8", expectedDigit: .number(8)),
        .init(charactor: "9", expectedDigit: .number(9)),
        .init(charactor: "+", expectedDigit: nil),
    ] as [DigitCondition])
    func init_from_character(_ condition: DigitCondition) {
        let actual = Digit(condition.charactor)
        #expect(actual == condition.expectedDigit)
    }
}

struct DigitCondition {
    var charactor: Character
    var expectedDigit: Digit?
}

所感

制限付きの簡易電卓でもかなり実装に苦労したので(2週間くらいかかった)、冪乗や冪根、三角関数なども可能な関数電卓の実装など途方もないなと感じました。皆さんも一度は電卓としっかり向き合ってみることをお勧めします。腕試しとしても、スキル磨きとしても良い課題だと思います。

Discussion