Tabelog Tech Blog

食べログの開発者による技術ブログです

理想のUIをめざして!Webでハーフモーダルを作って磨き上げた話

こんにちは!飲食店システム開発部オーダーチームの開発エンジニアを担当している堀口です。

食べログオーダーは、レストランでの飲食体験をより快適にするためのモバイルオーダーシステムです。飲食店に来店したお客様が自身のスマートフォンを使用してQRコードを読み取り、Web上でメニューをカートに追加し注文することができます。メニュー選択や注文操作はWebでありながら、ハーフモーダルを使用したネイティブアプリのような注文体験ができます。

この記事では、モバイルオーダーシステムのUI改善に焦点を当てます。ハーフモーダルの採用がどのようにして決定されたのか、その開発プロセス、そして実際に達成された改善点について詳しく掘り下げていきます。Reactを使用したフロントエンド開発で遭遇した課題と、それらをどのように解決したかの具体例を紹介します。

目次

なぜ「ハーフモーダル」を採用したか

食べログオーダーの開発過程において、店舗様や飲食店のお客様が直面する課題を解決し、効果的なUI設計を追求することが求められました。ここでは、ユーザー体験を向上させるために、なぜハーフモーダルの採用が重要だったのかを掘り下げます。

  1. 直感的なユーザーインターフェース
    食べログオーダーは、飲食店のお客様がスムーズで快適にメニューを選べることを重視しています。特に、片手操作に最適化されたハーフモーダルでは、スライドアップやダウンのアニメーションがハーフモーダルの開閉動作を直感的に示唆し、簡単なスワイプで閉じることができます。このおかげで、お客様は数多くのメニューの中から効率よく選択し、注文することができます。

  2. 効率的な情報の表示と操作性
    ハーフモーダルは、限られたスペースを最大限に活用し、メニューの詳細やアレルギー情報、追加オプションをコンパクトに表示します。この設計により、情報が明確になり、ユーザーが迅速に選択を行えます。

  3. タスク間のスムーズな移行
    ページ遷移や全画面モーダルと比べて、ハーフモーダルはユーザーが元のページに戻る際の違和感を軽減します。このアプローチにより、メニュー選択から注文確認までスムーズに移行ができ、操作中の混乱やストレスが減少します。

これらの改善点は、ユーザーが直感的に操作できるデザインへのこだわりと、モバイルファーストの設計理念を反映しています。そのため、特定のユーザー体験を実現するためにハーフモーダルのライブラリではなく、完全オリジナルのハーフモーダルで開発しました。これにより、iOSなどのモバイルOSで広く採用されているようなユーザー体験をWeb環境で再現し、画面スペースを効率的に利用できるようにしました。

ハーフモーダルの導入と初期の課題

ハーフモーダルの初期実装では、迅速なリリースを優先した結果、コードの複雑化が進み、バグが頻発する状況になりました。このような状態では理想的なユーザーインターフェースを構築することは困難であり、頻繁に発生するバグがレストランでの飲食体験を損ねてしまうことで、顧客満足度を低下させてしまいます。

改善プロセス

ハーフモーダルに関連する多くの課題に直面していたため、段階的に以下のステップでシステムを改善しました。

  1. 技術的負債の特定
  2. 技術的負債の解消
  3. 理想のUIを作成

1. 技術的負債の特定

パフォーマンス悪化とバグ発生の原因

過度にuseStateを使用したことで、不必要なコンポーネントの再レンダリングが頻繁に発生し、アプリケーションの応答性が著しく低下しました。特に、ハーフモーダルのスワイプやスクロール時の高さ調整をuseStateで管理していたため、不自然な動きを感じられることがありました。さらに、さまざまな用途でuseStateを使ったスタイル調整も多く、管理されている内容を把握するのは困難でした。ある案件では、ハーフモーダルの内容を修正後、リリースまでに15件のバグやデグレが発生し、UIが壊れやすくなっており、非常に困難な状況になっていました。 以下のようにReact Developer Toolsを使用し、レンダリング状況を可視化すると常に再レンダリングされていることが分かります。

  // 過度にuseStateで可変のスタイルを管理していた。例:高さ、ハーフモーダルの余白、スクロール位置
  const [modalCloseHeight, setModalCloseHeight] = useState(0); // 高さ
  const [modalPaddingHeight, setModalPaddingHeight] = useState(0); // 余白
  const [scrollTop, setScrollTop] = useState(0); // ページの上部からのスクロール位置
  const modalCloseThreshRate = 0.3; // 画面の何割までスワイプダウンしたら閉じるか 例:0.5なら半分、0.2ならかなり上部で閉じる

  const setUpModalHeight = (windowHeight: number) => {
    setModalCloseHeight(Math.round(windowHeight * modalCloseThreshRate));
    setModalPaddingHeight(windowHeight - modalCloseHeight);
    setScrollTop(modalPaddingHeight);
  };

  useEffect(() => {
    // 高さが変わる度に再レンダリングされる
    setUpModalHeight(height);
    updateStyleHeight(halfModalContentTopPaddingRef, modalPaddingHeight);
    (省略)
  },[height]);

React Render Before GIF

DOM構造の複雑性

ハーフモーダル内の構造が複雑で深くネストされていたため、DOMの読み込み速度に悪影響を及ぼし、メンテナンスが困難でした。閉じるアイコンやフッターが外部に配置されていることで、さらに構造が分かりにくくなっていました。

  <!-- DOM構造例 -->
  <div class="ハーフモーダル1">
    <div class="ハーフモーダル2">
      <div class="ハーフモーダル3">
        <div class="ハーフモーダル上部の余白"></div>
        <div class="ハーフモーダル4">
          <div class="メニュー詳細">
            <div class="ヘッダー">
              <div class="戻るボタンフレーム"></div>
            </div>
            <div class="メニュー内容"></div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <div class="ヘッダーの閉じるアイコン"></div>
  <div class="ハーフモーダルのフッター"></div>

スタイリングの課題

styled-componentsというCSS-in-JSライブラリを使用してスタイル管理をしています。CSSでの固定値や絶対位置によるスタイリングは、ブラウザやWebView間でのスタイル崩れを頻発させていました。食べログオーダーは、飲食店に来店したお客様向けのWebアプリケーションとしてブラウザ上で動作し、店舗の従業員が代理注文を行うためのWebViewを通じたモバイルアプリとしても機能しています。そのため、異なる環境でも表示の一貫性を保つ必要があります。以下に問題となっていたスタイル定義を示します。

  // スタイリング例
  export const StyledHalfModal1 = styled.div`
    padding-bottom: 0 0 230px; // 固定の余白が異なるデバイスでの表示を崩す。例えば、iOSでアドレスバーが表示された場合など。
  `;

  export const StyledHalfModal2 = styled.div`
    z-index: 102;
    position: absolute;
    left: 0;
    top: 485px; // 絶対位置の指定が異なるディスプレイサイズに対応しづらくする
 `;

  export const StyledHalfModal3 = styled.div`
    &.no-handy {
     height: calc(100vh - 180px); // ブラウザでの高さ調整
    };
  
    &.handy {
      height: calc(100vh - 170px); // WebViewでの高さ調整
    };
 `;

固定値でのpadding-bottom指定により、異なるデバイスでの表示を不均一にする問題があります。また、topの絶対値指定は、異なる画面サイズやコンテンツの更新時に適切な調整を要求し、スタイルの再利用を困難にします。さらに、heightの設定によって、ブラウザとWebView間での高さは異なるため、一部のデバイスでレイアウトは崩れる可能性があります。

2. 技術的負債の解消

パフォーマンス悪化とバグ発生の原因の解消

過去のアプローチでは、ハーフモーダルの動的なスタイル(例えば、高さや位置)を管理するためにuseStateを多用していました。この方法は不必要な再レンダリングを引き起こし、ユーザーインターフェースの応答性を損ねていました。以下では、onTouchStart、onTouchMove、onTouchEndの各イベントで使用する3つの関数を中心に、これらの問題をどのように解消したかを説明します。

以下は、useRefを使用してパフォーマンス問題を解消した具体例です。

  const handleTouchStart = (e: React.TouchEvent) => {
    e.stopPropagation(); // イベントの伝播を停止
    setTouchStartY(e.touches[0].clientY); // タッチ開始時のY座標を状態に保存
  };

この関数は画面タッチ時に呼び出され、イベントの伝播を防ぎながら、タッチされた位置のY座標を保存します。この情報は後続のタッチムーブやタッチエンドのイベントで利用されます。

  const handleTouchMove = (e: React.TouchEvent) => {
    e.stopPropagation(); // イベントの伝播を停止
    if (!modalContainerRef.current) return;
    const dragDistance = e.touches[0].clientY - touchStartY; // ドラッグ距離を計算
    if (dragDistance < 0) return; // 上にスワイプされた場合は無視

    // ハーフモーダルのスタイルを動的に更新
    Object.assign(modalContainerRef.current.style, {
      overflow: "hidden",
      transform: `translateY(${dragDistance}px)`,
    });
  };

この関数はユーザーがタッチしたまま指を動かしている間に繰り返し呼ばれる関数です。タッチ開始時に保存されたY座標と現在のY座標との差(ドラッグ距離)を計算しています。もし画面を下にドラッグしている場合(dragDistanceが0より大きい場合)、その距離だけハーフモーダルを下に動かします。overflow: "hidden"を設定して、スクロールを防ぎながらtransformでハーフモーダルを動的に移動させています。

const handleTouchEnd = async (e: React.TouchEvent) => {
  e.stopPropagation(); // イベントの伝播を停止
  if (!modalContainerRef.current) return;

  modalContainerRef.current.style.overflow = "auto"; // スクロールを再度有効化

  const dragDistance = e.changedTouches[0].clientY - touchStartY;
  const halfModalHeight = modalContainerRef.current.offsetHeight * 0.5;

  // ドラッグ距離がハーフモーダルの高さの半分を超えた場合
  if (dragDistance > halfModalHeight) {
    if (isDirty) {
      const shouldMove = await confirm(); // 確認ダイアログを表示

      if (!shouldMove) {
        Object.assign(modalContainerRef.current.style, {
          transition: "none",
          transform: "translateY(0)",
        });
        return;
      };
      dispatch(resetDirty()); // ユーザーが変更していた内容をリセット
    };
    handleClose(); // ハーフモーダルを閉じる処理
  } else {
    // ドラッグ距離が足りない場合、ハーフモーダルを元の位置に戻す
    Object.assign(modalContainerRef.current.style, {
      transform: "translateY(0)",
    });
  };
};

この関数はユーザーがタッチを終了した時(指を離した時)に呼ばれます。ここでドラッグの距離がハーフモーダルの半分の高さを超えると、ハーフモーダルを閉じる処理を行います。 また変更が未保存(isDirtyが真)の場合、confirm関数を用いてユーザーに確認を求めます。ユーザーが保存を拒否すれば、ハーフモーダルは元の高さの位置に戻り、閉じられません。この処理により、誤って重要なデータを失うことなく、直感的かつ安全にアプリを操作できるようにしています。

これらの改善により、状態管理もシンプルになりコードの複雑性が減少し、状態の不整合によるバグが起きにくい作りになりました。またレンダリング状況を確認すると、全体のレンダリング負荷が大幅に軽減されたことが分かります。

React Render after GIF

DOM構造の抽象化

元々のハーフモーダルは、複雑で深くネストされたDOM構造により、メンテナンスが困難であり、DOMの読み込み速度にも影響を与えていました。以下のように、各要素の役割を適切に抽象化し、構造を明確にすることで、より効率的にメンテナンスを行えるようにしました。

  // コンポーネント例
  export const MenuHalfModal = ({/* 省略 */}) => {

    const {
      modalContainerRef,
      handleTouchStart,
      handleTouchMove,
      handleTouchEnd,
    } = useHalfModal({
      ...{ handleClose: onSwipeClose, isDirty, confirm, isVisible },
    });

    return (
      <HalfModal
        {...{
          isVisible,
        }}
        >
        {/* ユーザーのスワイプ操作を感知し適切に反応するためのコンテナです。タッチイベントを処理し、インタラクティブな挙動をサポートします。 */}
        <HalfModalSwipeContainer
          {...{
            handleScroll,
            handleTouchStart,
            handleTouchMove,
            handleTouchEnd,
            ref: modalContainerRef,
          }}
        >
          <HalfModalHeader>
            {/* スクロールに関わらず常に上部に表示されます */}
          </HalfModalHeader>
          <HalfModalBody>
            {/* 主要なコンテンツが表示されるエリアで、スワイプによるスクロールが可能です */}
          </HalfModalBody>
        </HalfModalSwipeContainer>
        <HalfModalFooter>
          {/* スワイプ操作の影響を受けずに固定されています。カート追加や注文確定の重要な操作が配置され、ユーザーが常にアクセス可能な状態が保たれています。 */}
        </HalfModalFooter>
      </HalfModal>
    );
  };

  /*
    このコンポーネントはハーフモーダルの表示制御を担い、`isVisible`属性によってハーフモーダルの表示状態を管理します。
    またReactの機能の一つであるcreatePortalを使用し、document.bodyに直接レンダリングします。
    ハーフモーダルをコンポーネント内に置くと、親コンポーネントの `overflow`や`z-index`の影響で正しく表示されないことがあります。
    document.bodyに直接配置することで、これらの問題が解消され、ハーフモーダルが画面全体にきれいに表示され、他のUI要素と干渉しないようになります。
  */
  export const HalfModal = ({
    children,
    isVisible,
    {/*(省略)*/}
  }) => {
    //(省略)

    return (
      <>
        {createPortal(
          (isVisible) && (
            <>
              <Overlay />
              <StyledHalfModal>
                {children}
              </StyledHalfModal>
            </>
          ),
          document.body,
          "half-modal"
        )}
      </>
    );
  };

これらの改善により、ユーザーが直観的に操作できるよう設計され、各機能が明確に整理されました。新しい機能の追加や既存機能の修正を迅速に行うことができるようになり、システムの保守も効率化されました。

スタイリングの最適化

以前のスタイリングでは、固定値や絶対位置を多用することで異なるデバイス間での表示の一貫性が損なわれており、保守性にも課題がありました。この問題を解決するため、Flexboxを活用して以下のようにスタイルを再設計しました。

  // スタイリング例
  export const StyledHalfModal = styled.div`
    display: flex;
    flex-direction: column;
    height: 100%;
  `;

  export const StyledHalfModalHeader = styled.div`
    flex-shrink: 0; // サイズが縮小しないように設定
  `;

  export const StyledHalfModalBody = styled.div`
    flex-grow: 1; // 利用可能なスペースをすべて使用
    overflow: auto; // 必要に応じてスクロールを表示
  `;

  export const StyledHalfModalFooter = styled.div`
    flex-shrink: 0; // サイズが縮小しないように設定
  `;

Flexboxを使用し、要素の配置を柔軟に行い、デバイスに依存しない一貫したスタイリングを実現しました。また、固定値ではなく相対的なサイズ指定を採用することで、異なる画面サイズに自動的に調整されます。このアプローチにより、スタイルの再利用性と保守性が向上しました。結果として、ユーザーにとっても一貫した見た目を提供できるようになりました。

3. 理想のUIを実現

ハーフモーダルの技術的負債を解消したことで、理想のUIを実現できるフェーズになりました。 食べログオーダーのユーザー体験向上を目指し、デザイナーとエンジニアが協力してハーフモーダルの再設計をしました。以前は複数のハーフモーダルが同時に表示される状況を効果的に管理できませんでした。これを克服するために、ハーフモーダル間の視覚的階層を明確化しました。これにより、ユーザーは操作中のハーフモーダルを直感的に識別できるようになりました。

ハーフモーダル間の階層を表現

ハーフモーダルのコンポーネントに階層情報を各コンポーネントにわたす手段として、ReactのcreateContextを採用し、LevelContextProviderを定義することで、階層管理をハーフモーダル間で行えるようにしました。このコンテキストは、ハーフモーダルが表示される際に、その階層情報を子コンポーネントに渡す役割を持ちます。これにより、異なるコンポーネント間で状態を共有し、どのハーフモーダルがアクティブであるか、どのように表示すべきかを制御できるようにしました。

たとえば、各コンポーネントにlevelプロパティを渡すことで、スタイルや動作をレベルに応じて適切に調整できます。

  <LevelContextProvider {...{ level }}>
    <HalfModal {...{ isVisible }}>
      {/* ハーフモーダルに関するコンポーネント内で階層情報のlevelを取得できる */}
    </HalfModal>
  </LevelContextProvider>
  // スタイリング例
  export const StyledHalfModal = styled.div`
    z-index: ${(props) => calcModalZIndex(props.level) + 1}; // 階層に応じたz-index。1の場合:1101 2の場合:1201
    height: ${(props) => props.height}; // 階層ごとに高さを調整
  `;

  // ヘッダーやフッター等にも可変に対応できるようにする
  export const StyledHalfModalHeader = styled.div`
    z-index: ${(props) => calcModalZIndex(props.level) + 2};
  `;

Transformで立体感の演出

アクティブでないハーフモーダルは、transform: scale(0.95)を適用して少し縮小させました。これにより立体感が生まれ、現在アクティブなハーフモーダルへの視覚的なフォーカスが強化しました。この変更により、どのハーフモーダルが前面かを識別できるようになりました。

  // スタイリング例
  export const StyledHalfModal = styled.div`
    &.under-level {
      transform: scale(0.95);
    };
  `;

これらの技術的改善により、以下の動画のようにユーザーは複数のハーフモーダルが存在しても、どのハーフモーダルがアクティブであるかを容易に識別でき、その結果より快適で効率的な操作が可能になりました。

Half Modal Level GIF

改善の成果

ハーフモーダルの再設計と実装に伴う困難は大きかったものの、計画的なアプローチと技術的負債の解消により、理想的なUIを実現できました。このプロジェクトを通じて得られた主な成果は以下の通りです。

運用の容易さ
新しいハーフモーダルの導入により、システムの運用が大幅に簡易化されました。特に再設計後の半年間でハーフモーダル関連のバグがほとんど発生しなかったため、バグ対応にかかる時間と労力が削減されました。この結果、新機能の追加やその他の改善作業に集中することが可能になりました。

タブレットへの対応
導入いただく店舗様が増えていくにつれ、店舗様からの新たな要望にも迅速に応えていく必要があります。ハーフモーダルの再設計後の恩恵もあり、タブレットでの表示対応という新たな要望に対して、システムの柔軟性を活かしました。開発からリリースまでわずか1週間で完了し、店舗様の期待に応えることができました。

インタラクティブな操作性の向上
タッチイベントの最適化により、ユーザーはスムーズにスクロールやスワイプを行えるようになりました。操作中のストレスの軽減がユーザー体験の向上につながり、これがアプリケーションへの満足度向上に繋がることを期待しています。

最後に

このプロジェクトを通じて、理想的なユーザーインターフェイスの実現に向けた重要な一歩を踏み出すことができました。今後はデータに基づいた意思決定を行いながら、特にハーフモーダルのユーザー体験をさらに向上させることに注力したいです。ユーザーからのフィードバックや操作ログを詳細に分析し、そのデータをもとに具体的な改善点を特定し、対応していけるようにしたいです。私たちの取り組みが、他の組織の皆さんにも何かの参考になれば幸いです。最後までお読みいただきありがとうございました。

私たちは食べログオーダーを共に開発いただける方を募集しています。 気になった方は是非下記の採用リンクをチェックしてみてください!