以下の内容はhttps://tech.newmo.me/entry/rideshare-driver-swiftui-portfolioより取得しました。


【お蔵入り】ライドシェアドライバーアプリのSwiftUI作品集!

newmoでソフトウェアエンジニアをしているはるふ(@_ha1f)です。

今はAI配車チームにいますが、昨年の今頃は、ライドシェアのドライバー向けのiOSアプリを開発しておりました。

ライドシェアドライバーアプリは、ドライバーが乗務を開始して、配車依頼を受けて、お客さんを迎えに行って送り届ける。そのフローを支えるアプリです。 主としてTCA(The Composable Architecture)とSwiftUIで構築し、Google Maps Navigation SDKでリアルタイムのナビゲーションを行っていました。 タクシーの車載用のアプリも並行して作っており、バックエンドは多くの部分を共有しています。

2024年12月半ばにチームが発足し、2025年の1月末には実車テスト、2月にはTestFlightで実際にドライバーに使っていただくというとんでもないスケジュールでした。 開発速度はもちろん、クオリティも妥協せずにリリースできたのは素晴らしいチームに恵まれていたと感じます。

一方で、会社全体として、ライドシェアを巡る事業環境の変化・注力分野の変更など紆余曲折があり、 結果的に残念ながらこのアプリは一般向けにリリースすることはなく、チームも解散となりました。

(※事業的な話については、この記事などをご覧ください)

ただ先述の通りUIもかなりこだわって作っており、供養もかねてテックブログで紹介します。

以降では、このアプリのこだわりの一部を、実装とともに紹介していきます。

※ アニメーションの実装は自分自身のSwiftUIのリハビリを含めて色々なバリエーションで作っており、最適なものを選んでいるとは限りません

配車受付状態を切り替えるバー


ほなーくんと走るホーム画面

アプリを開いて最初に目にするのがホーム画面です。ここにはマスコットキャラクターのほなーくんが車に乗って走っているアニメーションを置きました。 最初に目にする画面なので、待っている間にちょっとワクワクしてもらいたくてデザイナーさんと協力して作りました。

構成はシンプルで、道路、ビル群、雲、車体、ほなーくんの手、前景の草などをZStackで重ねているだけです。

ZStack(alignment: .bottom) {
  Image(.road)
  _InfiniteScrollView {
    Image(.buildings)
  }
  Image(.cloud)
  Image(.tire)
  Image(.carBody)
    .offset(y: carYOffset)
  Image(.honaaHand)
  Image(.grassFront)
}

昔の2Dゲームのスクロールのように、背景のビル群だけが横に流れ、車体はその場で揺れています。

背景の無限スクロールはTimelineView(.animation)で実装しています。現在時刻から位相を計算して、オフセットを決めるというアプローチです。

TimelineView(.animation) { context in
  let offsetX = getTimelineValue(
    startValue: -contentSize.width,
    endValue: 0,
    date: context.date,
    duration: animationDuration
  )

  HStack(spacing: 0) {
    ForEach(0..<3, id: \.self) { _ in
      content()
    }
  }
  .offset(x: offsetX)
}

同じ画像を3枚並べてオフセットをずらしていくことで、切れ目のないループになります。値の計算は、時刻を周期で割った余りから線形補間するだけです。

let phase = (interval - floor(interval / duration) * duration) / duration
return (endValue - startValue) * phase + startValue

車体の上下の揺れはもっとシンプルで、repeatForeverで動かしています。 敢えて独立にしてリアルさを出してみました。

withAnimation(
  .linear(duration: 0.2)
    .delay(0.1)
    .repeatForever(autoreverses: true)
) {
  carYOffset = -2
}

デザイナーさんと密に連携し、空白セルで画像のサイズを揃えてもらったり、レイヤをうまいこと分割・合成いただいたことでコードがシンプルになりました。 腕が車とわかれて動いているのがポイントだそうです。


配車依頼のカウントダウン

乗務を開始すると、配車依頼が届きます。ドライバーには受諾するまでの制限時間が円形のプログレスで表示されます。

countdown

Circle の trim で円弧を描く

円形プログレスの本体は Circletrim で描いています。

struct CircularIndicator: View {
  let progress: CGFloat
  let lineWidth: CGFloat

  var body: some View {
    ZStack {
      Circle()
        .stroke(Color(.secondarySystemBackground), lineWidth: lineWidth)
      Circle()
        .trim(from: 0, to: progress)
        .stroke(.tint, style: .init(lineWidth: lineWidth, lineCap: .round))
        .rotationEffect(.degrees(-90))
    }
  }
}

背景に薄い円、前景に trim で切り取った円を重ねています。Circle の描画は3時方向から始まるので、.rotationEffect(.degrees(-90)) で12時方向に回しています。

これを ProgressViewStyle に準拠させて、標準の ProgressView で使えるようにしています。スタイルの差し替えがしやすくなるほか、アクセシビリティ対応が自動で付くメリットもあります。

struct CircularBarProgressStyle: ProgressViewStyle {
  let lineWidth: CGFloat

  func makeBody(configuration: Configuration) -> some View {
    CircularIndicator(
      progress: configuration.fractionCompleted ?? 0,
      lineWidth: lineWidth
    )
  }
}

TimelineView で滑らかに減らす

カウントダウンのアニメーションは TimelineView(.animation) で駆動しています。毎フレーム ctx.date が更新されるので、それに合わせてフレームを計算します。

TimelineView(.animation) { ctx in
  ZStack(alignment: .center) {
    ProgressView(value: store.state.calcProgress(now: ctx.date), total: 1)
      .progressViewStyle(CircularBarProgressStyle(lineWidth: 6))
    Text(verbatim: String(store.state.calcRemainingSeconds(now: ctx.date)))
      .animation(.none) // 数字はフェードさせない
  }
}

calcProgress はなめらかに、一方で残り秒数は .rounded(.up) で切り上げています。

func calcProgress(now: Date) -> CGFloat {
  let interval = deadline.timeIntervalSince(countDownStartAt)
  guard interval > 0 else { return 0 }
  let elapsed = now.timeIntervalSince(countDownStartAt)
  return 1 - max(min((elapsed / interval), 1), 0)
}

func calcRemainingSeconds(now: Date) -> Int {
  max(Int(deadline.timeIntervalSince(now).rounded(.up)), 0)
}

ライド中の進捗表示

ライド中の進捗表示では、区間の状態に応じて実線・点線・アニメーションするグラデーションバーを使い分けています。

progress

SwiftUI で点線を描く

実線は Capsule を塗るだけですが、点線は Path + StrokeStyle で描画しています。

Path { path in
  path.move(to: CGPoint(x: 0, y: center))
  path.addLine(to: CGPoint(x: width, y: center))
}
.stroke(
  style: .init(
    lineWidth: barHeight,
    lineCap: .round,
    dash: [6, 10, 0, 10, 0, 10]
  )
)
.clipShape(Capsule())

StrokeStyledash は「描く長さ、空白の長さ」の繰り返しです。[6, 10] なら「6pt描いて10pt空ける」の単純な破線になりますが、ここでは [6, 10, 0, 10, 0, 10] としています。描画長 0 でも lineCap: .round がついていると丸い点として描画されるので、「短い棒 → 間隔 → 丸い点 → 間隔 → 丸い点 → 間隔」というリズムの点線パターンになります。

なお、Path の終点が描画領域ぴったりだと最後の dash が途切れて見えるので、少し長めに引いて .clipShape(Capsule()) でトリミングしています。

脈打つグラデーションバー

現在進行中の区間には、グラデーションが伸びるプログレスバーを使っています。 PhaseAnimatorで3つのフェーズを回しています。

private enum AnimationStep: CaseIterable {
  case up       // バーが伸びる
  case fadeOut   // フェードアウト
  case reset     // 元に戻る
}
PhaseAnimator(AnimationStep.allCases) { step in
  ProgressView(value: progressValue(for: step), total: total)
    .progressViewStyle(
      GradientProgressStyle(height: height, barOpacity: opacity(for: step))
    )
} animation: { step in
  switch step {
  case .up:      .spring(duration: 0.7)
  case .fadeOut:  .spring(duration: 0.3).delay(0.5)
  case .reset:   .none
  }
}

バーがspringで伸びて、少し止まってからフェードアウトして、瞬時にリセットされる。この「少し止まって」の部分 (.delay(0.5)) が大事。


オドメーターと体温の入力

乗務を開始する前に、ドライバーはオドメーター(走行距離)の値や体温を入力します。

1桁ずつ独立したボックスを並べるUIを採用しています。実装としては、見えないダミーのTextFieldに入力させて、その値を1文字ずつ分割して各ボックスに反映するという仕組みです。

隠しTextField パターン

// 表示用のボックス(すべて disabled)
HStack {
  ForEach(values.indices, id: \.self) { index in
    TextField(text: $values[index]) { Text(verbatim: "0") }
      .textFieldStyle(.numberBox(isHighlighted: isFocused && index == inputTarget))
      .disabled(true)
  }
}
.background {
  // 実際に入力を受ける隠しフィールド
  DigitsTextField(text: $dummyFieldValue, maxLength: values.count) {
    Text(verbatim: "")
  }
  .opacity(0)
}

タップすると隠れた DigitsTextField にフォーカスが当たり、入力された文字列は Reducer で1文字ずつ分割されて values 配列に反映され、表示用ボックスが更新されます。

各ボックスには、現在の入力位置をハイライトするカスタムのTextFieldStyleを当てています。 アクセントカラーの枠でカーソル位置を指しています。

DigitsTextField の中身

DigitsTextField は数字のみを受け付ける専用のTextFieldです。

struct DigitsTextField<Label: View>: View {
  let maxLength: Int
  let label: () -> Label

  @Binding private var text: String
  @State private var internalText: String
  @State private var feedbackTrigger = false

  var body: some View {
    TextField(text: $internalText) { label() }
      .keyboardType(.numberPad)
      .onChange(of: internalText, initial: false) { _, newValue in
        if newValue.count > maxLength {
          feedbackTrigger.toggle()
        }
        let extracted = Self.extractDigitsString(newValue, maxLength: maxLength)
        if newValue != extracted {
          internalText = extracted
        }
        text = extracted
      }
      .sensoryFeedback(.warning, trigger: feedbackTrigger)
  }

  private static func extractDigitsString(_ input: String, maxLength: Int) -> String {
    input.firstMatch(of: /[0-9]+/)
      .map { String($0.0.prefix(maxLength)) }
      ?? ""
  }
}

@Bindingtext とは別に @State private var internalText を持つことで、数字以外の入力を上書きしています。

.sensoryFeedback(.warning) みたいな最大文字数チェックもさわり心地が良いですよね。

体温と走行距離での使い分け

同じ仕組みを体温入力(3桁)と走行距離入力(6桁)で共有していますが、文字列のアライメントが違います。

  • 体温: 左詰め。"36" → ["3", "6", ""] で、2桁目と3桁目の間に小数点を置く
  • 走行距離: 右詰め。"123" → ["", "", "", "1", "2", "3"] で、オドメーターの見た目に合わせる

Reducer 側で paddingRight / paddingLeft (空白文字を詰める方向) を切り替えているだけで、View の構造はほぼ同じです。


連絡事項の入力欄

連絡事項の入力欄も同じ方針で、フォーカス時に枠を出しています。

func _body(configuration: TextField<Self._Label>) -> some View {
  configuration
    .padding(16)
    .background {
      Color(.secondarySystemBackground)
        .clipShape(RoundedRectangle(cornerRadius: CornerRadius.small))
    }
    .overlay {
      if highlighted {
        RoundedRectangle(cornerRadius: CornerRadius.small)
          .strokeBorder(Color.accentColor)
      }
    }
}

フルスクラッチのボトムシート

地図の上にオーバーレイするハーフモーダルの情報パネルは、Apple純正の.sheetを使わずフルスクラッチで実装しました。

ハーフモーダル

iOSのバージョンによってmodal on modal(シートの上にシートやアラートを出す)の挙動が不安定で、純正のシートでは不安が大きかったのが主要因です。

一方でiOSユーザーにはおなじみの手触りが損なわれると違和感が大きいので、実装は見た目以上に苦戦しました。

一つは、引っ張るほど抵抗が増す感触です。

spring

シートを限界を超えてドラッグしたときのバネ感は、sqrtで表現しています。

if nextHeight > maxSheetHeight {
  let diff = nextHeight - maxSheetHeight
  sheetHeight = maxSheetHeight + min(sqrt(diff * 10), diff)
}

シートの高さに応じて背景のブラー強度も変えています。シートが低いうちは地図がはっきり見えて、引き上げるにつれてぼやけていく。

func getBlurRadius() -> CGFloat {
  let threshold = max(maxHeight / 2, defaultSheetHeight ?? 0)
  let progress = (sheetHeight - threshold) / (maxHeight - threshold)
  return (progress * 30).clamp(minValue: 0, maxValue: 30)
}

シートが大きくなったときは地図を見る必要がないので、自然と視線が情報パネルに集まるようになります。 意図的にフォーカスを誘導する、ちょっとした工夫にも実装を通して気が付きました。

ちなみに、ダイアログも全て独自実装です。


おわりに

アプリと別で、UIを実際に触っていただくための専用のビルドを用意したりして何度もデザイナーに実際に触っていただき、 短い時間の中でブラッシュアップを重ねました。

アニメーションも0.1秒ごとに変えたり比率変えたり、色々なバリエーションを用意して並べて見てもらったり。 そういう小規模チームならではの開発も楽しかったです。

サンプル集

没になったボタン

この記事が、UIを丁寧に作りたいと思っている誰かの参考になれば嬉しいです。




以上の内容はhttps://tech.newmo.me/entry/rideshare-driver-swiftui-portfolioより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14