こんにちは。スマートバンクで iOS / Android エンジニアをしている nakamuuu です。
2021年4月に正式公開されたiOS版B/43は、先月にリリース1周年を迎えました 🎉
この1年の間にはAndroid版(2021年12月公開)の準備も並行して進めつつ、
- B/43をふたりで使える "ペア口座" 機能
- セブン銀行ATMやクレジットカード、あとばらいチャージなどの入金方法の拡充
- 本人確認(eKYC)を含むオンボーディングフローの継続的な改善
などの開発を精力的に進めてきました。今後もたくさんの機能の拡充や改善を予定しているのでご期待ください!
さて、この記事ではiOS版B/43のホーム画面の "残高グラフ" について、その機能と設計を紹介していきます。
ホーム画面の残高グラフについて
B/43のホーム画面の上部では過去の残高を折れ線グラフの形で表示しています。 リリース当初から実装されていて、ユーザーさんの目に一番多く触れているコンポーネントではないでしょうか。
この残高グラフは主に次のような機能を持っています。
- 過去の残高を1ヶ月ごとにページを区切って表示
- ホーム画面上のタイムライン(履歴)のスクロール位置との同期
- タップ / スワイプ操作に対するインタラクティブな挙動
ただ静的な折れ線グラフを表示するだけならば、自前での実装もそれほど難しいものではありません。 しかし、残高グラフに上述した機能を持たせるには、そのデータソースやViewの構成にもさまざまな考慮が必要となってきます。
(↓ タイムラインのスクロール位置との同期の挙動)
データソースの設計
ホーム画面の残高グラフでは "毎日24時時点での残高" を頂点としてプロットしています。愚直に実装するならば、その一覧を1ヶ月単位で返すようなAPIを用意する方法が考えられます。
しかし、残高グラフ用のデータソースを独立して設計してしまうと、
- "残高グラフ用のデータ" と "タイムライン" の取得状況がずれるとスクロール位置の同期が動作しなくなる
- 日本以外のタイムゾーンに対するAPI側での考慮が煩雑となる
などの課題が生じてしまいます。このため、残高グラフは取得済みのタイムラインのデータを用いて表示する設計としました。この設計を簡単に図示すると次の通りです。
一方でこの設計にすると、タイムラインを1ヶ月単位で取得しないとグラフの表示が欠損してしまいます。APIの設計として考慮することも可能かもしれませんが、現状はアプリ側で1ヶ月分に満たないデータの考慮を上手く行うことでこの問題を回避しています。
以下の図の例だと、取得したタイムラインをそのまま表示に反映すると残高グラフの2ヶ月前(3月分)のページが不完全な形で描画されてしまいます。こういったケースでは末尾の1ヶ月分に満たないタイムラインのデータを一旦表示に反映しないような考慮をしています。
また、初回のリクエストで取得したデータが直近1ヶ月分に満たない場合は再帰的に次ページを取得することで、残高グラフの表示が欠損することを防いでいます。
少々泥臭い力技であることは否めませんが、残高グラフの表示の完全性を担保する考慮がアプリ側で完結するような設計になっています。
残高グラフ部分のViewの構成
続いて、残高グラフの表示寄りの実装の話に入っていきます。
折れ線グラフの描画では外部のライブラリの使用も検討しましたが、「タイムラインのスクロール位置との同期」などのニッチな要件を満たしづらいので自前で描画することにしました。複数ページに渡るスクロール可能なグラフを描画するために、iOSでは CATiledLayer を用いて構成しています。
CATiledLayer は UIScrollView と組み合わせることで表示されている領域のみの描画を効率的に行うことができます。B/43の残高グラフは次の図のようなViewの構成になっています。
UIScrollView に内包される UIView は "画面幅 x 取得したタイムラインの月数" という非常に大きな横幅を持っています。この UIView の layerClass
を以下のように CATiledLayer に指定することで表示された領域のみの折れ線グラフを描画するようにしています。
/// UIScrollView に内包される UIView final class BalanceGraphContentView: UIView { override class var layerClass: AnyClass { CATiledLayer.self } // 親Viewから "画面幅 x 月数" の横幅となるように設定される override var bounds: CGRect { didSet { let tiledLayer = layer as! CATiledLayer let tileWidth = bounds.width / pageCount * tiledLayer.contentsScale tiledLayer.tileSize = CGSize(width: tileWidth, height: bounds.height * tiledLayer.contentsScale) setNeedsDisplay() } } override func draw(_ rect: CGRect) { let graphPath: UIBezierPath = /* rectの範囲内の折れ線グラフのパスを生成 */ graphPath.stroke() } }
func draw(_ rect: CGRect)
にはCATiledLayer#tileSize
の大きさを持つ領域が渡ってくるため、1ヶ月(1ページ)分ごとに折れ線グラフのパスを生成・描画することができます。
実装した際には数十年分に相当するデータを生成して検証しましたが、体感できるパフォーマンスの低下は見られませんでした。
タイムラインのスクロール位置との同期
残高グラフとタイムラインとの双方向の同期は Android版のリリース時のエントリー でも触れられている通り、実装面での苦労の多い部分でした。 双方向の同期について "ユーザーの操作" と "連動した挙動" に分解したものが次の図です。
今のところ残高グラフを含むホーム画面全体はUIKitで実装しているため、それぞれのフローを手続き的に記述しています。タイムラインのスクロール時に "データ次第では残高グラフを一気に複数ページ切り替える必要もある" (B/43の利用を中断していてタイムラインの日付が1ヶ月以上歯抜けているケース) など、細かなエッジケースの考慮も必要でした。
表示状態を上手く抽象化できれば、こういった相互に挙動が同期される構成は宣言的なUIフレームワークと相性の良いのかもしれません。SwiftUIに将来書き換える際にはAndroid版のJetpack Composeでの実装を参考に…と思っていますが、フレームワーク固有の挙動や制約によって悩まされるのも見てきたので、そうシンプルにはいかないのかなと怖気づいています。
最後に
ここまで、iOS版B/43のホーム画面の "残高グラフ" の設計について紹介してきました。
B/43では全体としてはiOS / Androidの標準的な見栄えや挙動に倣って実装量を抑えていく設計です。その中で、今回紹介した残高グラフなどの重要なアクセントとなる要素にはコストをかけて実装をしています。アプリエンジニアにとっても力量を試される "頑張りどころ" で面白いポイントです。
今後開発していく機能においても、少し凝った表現を実装した際にはどこかで紹介させていただこうと思っています!
スマートバンクでは一緒に B/43 を作り上げていくメンバーを募集しています!カジュアル面談も受け付けていますので、お気軽にご応募ください 🙌 smartbank.co.jp