STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

予約システムとひとつになったPOSレジアプリの技術的なチャレンジを振り返る

こんにちは! STORES レジ の開発をしている iOS / Android エンジニアの @satoryo056 です。
STORES レジ は今年1月に STORES 予約 との連携を開始しました!
リリースから約半年が経ってしまいましたが、私が業務で iOS 開発を開始して以降初めての大型プロジェクトだったため、技術的なチャレンジを中心に振り返っていきたいと思います。

レジと予約の連携について

STORES 予約 が持っている予約情報が STORES レジ に連携され、レジ上で予約の確認や変更を行ったり、予約内容のお会計ができるようになりました!
また予約情報だけでなくお客様の予約履歴やカルテ、STORES レジ に登録している商品の購買履歴を閲覧することができます。
詳細はプレスリリースをご覧ください。

www.st.inc

STORES レジ で予約情報を表示する機能について

私たちはレジ・予約の連携機能の1つとして、スタッフが縦に並びスタッフごとに予約を表示する機能を実装しました。
具体的には以下のような画面を実装しました。本ブログではこの画面を「カレンダー」と呼びます。

STORES レジ アプリ:カレンダー画面

カレンダーの実装について

それではカレンダーの実装について技術的なチャレンジを紹介します。 なお、本ブログに記載しているソースコードは執筆用に書き直したものであり、実際のプロダクトコードではありません のでご注意ください。

時間を考慮して予約のViewを描画

カレンダーは、1日分(0:00〜23:59) の予約スケジュールを表示します。
予約情報から「予約の開始時間」「予約の終了時間」「担当スタッフ」の情報を抽出して、カレンダーの位置と長さを判定してViewを描画します。
また開始時間と終了時間の差が30分未満の予約に関しては、カレンダー上での見やすさやタップ領域が狭くなることを考慮して、30分の長さに固定して描画します。

開発中のカレンダー:予約時間の長さを判定して描画

/// カレンダーに表示する予約のViewの長さを求める
/// - Parameters:
///    - width: カレンダーに表示する item の長さ(任意の値)
func itemWidth() -> Double {
    let interval = reservationInterval(startAt: startAt, endAt: adjustedEndAt)
    return interval * width
}

/// 30分以下の予約の場合、開始時刻の30分後を終了時刻として扱う
/// - Parameters:
///     - startAt: 予約の開始時刻
///     - endAt: 予約の終了時刻
var adjustedEndAt: Date {
    max(startAt.addingTimeInterval(1800), endAt)
}

/// 0時から何時間経過したか計算する
func intervalFromDate(date: Date) -> Double {
    // 今日の0時
    let currentDate = Calendar.current.date(byAdding: .day, value: 0, to: Calendar.current.startOfDay(for: Date()))
    let timeInterval = date.timeIntervalSince(currentDate)
    // timeInterval は秒なので時間に変換
    return Double(timeInterval / 3600)
}

/// 開始時間と終了時間から予約の長さを求める
func reservationInterval(startAt: Date, endAt: Date) {
    let interval = intervalFromDate(startAt) - intervalFromDate(endAt)
    return interval
}

スタッフの同時対応の考慮

STORES 予約 の仕様にスタッフの「同時可能予約数」という概念があり、1人のスタッフに対して同じ時間に複数の予約を入れることができます。
そのため STORES レジ のカレンダーでは、1人のスタッフに同じ時間に入った予約や時間が被っている予約がある場合にスタッフ列の高さを拡大する必要があります。

幸い、カレンダーに表示する予約の高さや余白は固定の値なので、以下の計算式でスタッフ列の高さを算出することができます。

/// - Parameters:
///    - index: 縦に並ぶ予約の数
///    - height: カレンダーに表示する item の高さ
///    - padding: 縦に並ぶ予約の間の余白
func lineHeight() -> Double {
    Double(index) * (height + padding)
}

そのためカレンダーに予約を表示する前に、予約情報を取得後スタッフごとの高さを算出する必要があります。

開発中のカレンダー:縦に並ぶ予約

スクロールと描画

STORES レジ のカレンダーは縦方向・横方向どちらにもスクロールする必要があるため、スクロールに合わせてスタッフと時間の正確な位置に予約を表示し続ける必要があります。
これが最も実装に時間がかかった部分になります。

開発中の画面をいくつか載せますが、予約を表示する部分が上詰めにならず中央に配置されてしまったり、スクロールの位置がずれてしまうなど不具合も多く当初はリリースできる状態ではありませんでした。

 
開発中のカレンダー(左:予約が中央に配置される、右:横スクロール位置がずれる)

最終的に辿り着いた答えは「複数の ScrollView を連動して同時に動かす」ことでした。
以下の画像のように、スタッフ・時間・予約の3つをそれぞれ独立した ScrollView として扱い、縦方向へスクロールする際はスタッフ・予約を連動し、横方向へスクロールする際は時間・予約を連動するように実装しました。

3つのScrollViewに分けて考えたイメージ

実装の参考にしたのが SimultaneouslyScrollView です。
ScrollViewHandler に複数の ScrollView を紐づけることで、連動して同時にスクロールすることを実現する OSS になります。

github.com
Copyright (c) 2022 David Steinacher Released under the MIT license https://github.com/stonko1994/SimultaneouslyScrollView/blob/main/LICENSE


簡単に実装を紹介すると、まず ScrollViewHandler で扱う ScrollView を保存する Store を用意します。
ScrollViewHandler.register() を呼び出すことで Handler に ScrollView を保存することができます。

private var scrollViewsStore: [ScrollViewDecorator] = []
...
func register(scrollView: UIScrollView) {
    register(scrollView: scrollView, scrollDirections: nil)
}

func register(scrollView: UIScrollView, scrollDirections: SimultaneouslyScrollViewDirection?) {
    guard !scrollViewsStore.contains(where: { $0.scrollView == scrollView }) else {
        return
    }

    // store に保存
    scrollView.delegate = self
    scrollViewsStore.append(
        ScrollViewDecorator(
            scrollView: scrollView,
            directions: scrollDirections
        )
    )
    ...
}

private func sync(scrollView: UIScrollView, with decorator: ScrollViewDecorator) {
    ...

    // store に保存したすべての ScrollView をスクロールした方向へ動かす
    switch decorator.directions {
    case [.horizontal]:
        let offset = CGPoint(x: scrollView.contentOffset.x, y: registeredScrollView.contentOffset.y)
        registeredScrollView.setContentOffset(offset, animated: false)
    case [.vertical]:
        let offset = CGPoint(x: registeredScrollView.contentOffset.x, y: scrollView.contentOffset.y)
        registeredScrollView.setContentOffset(offset, animated: false)
    default:
        registeredScrollView.setContentOffset(scrollView.contentOffset, animated: false)
    }
}

また Handler に UIScrollViewDelegate を継承した extension を用意します。
ここでは Handler に保存した ScrollView がドラッグやスクロールした時に他の ScrollView もその位置へ移動するように処理します。
これにより複数の ScrollView が連動し同時にスクロールすることが可能になりました。

extension DefaultSimultaneouslyScrollViewHandler: UIScrollViewDelegate {
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        lastScrollingScrollView = scrollView
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        ...
        guard lastScrollingScrollView == scrollView else {
            return
        }

        scrollViewsStore
            .filter { $0.scrollView != lastScrollingScrollView }
            .forEach { sync(scrollView: scrollView, with: $0) }
    }
}

カレンダーでは ScrollViewHandler を2つ用意し、それぞれ縦方向用・横方向用として扱います。
スタッフの ScrollView には縦方向の Handler を、時間の ScrollView には横方向の Handler を、予約の ScrollView には両方の Handler を register() するだけで簡単にスクロールの連動ができます。

public enum ScrollViewHandlerFactory {
    public static func create() -> DefaultSimultaneouslyScrollViewHandler {
        DefaultSimultaneouslyScrollViewHandlerImpl()
    }
}

private let verticallyScrollViewHandler = ScrollViewHandlerFactory.create()
private let horizontallyScrollViewHandler = ScrollViewHandlerFactory.create()

// カレンダー描画
VStack {
    // 予約スケジュール・時間を表示(horizontal scroll)
    ScrollView {
        ...
    }
    .introspectScrollView {
        // 横方向にスクロールした時に連動
        horizontallyScrollViewHandler.register(scrollView: $0)
    }
    
    HStack {
        // スタッフの一覧を表示(vertical scroll)
        ScrollView {
            ...
        }
        .introspectScrollView {
            // 縦方向にスクロールした時に連動
            verticallyScrollViewHandler.register(scrollView: $0)
        }       
        // 予約を表示
        ScrollView(.horizontal) {
            ScrollView(.vertical) {
                // 表示したい予約 item がここに入る
                ...
            }
            .introspectScrollView {
                // 縦方向にスクロールした時に連動
                verticallyScrollViewHandler.register(scrollView: $0)
            }  
        }
        .introspectScrollView {
            // 横方向にスクロールした時に連動
            horizontallyScrollViewHandler.register(scrollView: $0)
        }  
    }
}

開発中のカレンダー:SimultaneouslyScrollViewを適用後

終わりに

STORES レジ ・ STORES 予約 の連携におけるカレンダーについて技術的なチャレンジについて紹介しました。
カレンダー以外にも予約との連携機能はいくつかリリースしていますがそれで完成ではなく、今後も機能追加やアップデートを予定しているのでまた紹介できればと思っています。

STORES では一緒に働く仲間を募集しています。このブログを見て STORES レジ に興味が出た!モバイル開発の話を聞いてみたい!という方がいましたらぜひお話ししましょう!

またオフィスでご飯を食べながら社員と話ができる BeerBash というイベントも定期的に開催しているので、そちらもぜひお願いします!

jobs.st.inc

coubic.com