【Unityで円形メニューUIを実装する】

はじめまして、コンテンツ開発事業部の岩崎と申します。 2019年3月11日より入社させていただき、普段はUnityを用いてゲームアプリの開発を行っております。 今回は直近のプロジェクトで、円形メニューUIを作成した時のお話を書こうと思います。

【この記事を書こうとした経緯】

当時、開発中アプリのホーム画面に、複数のボタンを配置し、それらを回して選択できる円形メニューUIを担当することになりました。

開発中何度も試行錯誤を繰り返し完成させたのですが、試作段階から完成までの過程で、 少しの工夫を加えただけで、UIがもたらす印象ががらりと変わり、ユーザーが使用する"機能"にも満たないUIの重要性を改めて実感しました。

そこで、アプリの印象やクオリティの向上を目指されている方々に少しでも参考になればと思い、この記事を書こうとした次第です。


開発を始める前の情報収集の際に、自動で円運動を行っている記事がありました。 shamaton.orz.hm

こちらを参考とさせていただき、まずは挙動確認用として試作段階の円形メニューを作ることにしました。

【試作段階の円形メニュー】

挙動を確認するための試作段階では、下記の仕様で作成しました。

  1. ボタンを均等に自動配置する処理
  2. ドラッグして回転させる処理
  3. 慣性の速度を徐々に落としていく処理
  4. ボタン群を起点に瞬時に合わせる処理

f:id:Teco_Iwazaki:20200318104012g:plain

1.ボタンを均等に自動配置する処理

private void AutomaticPlacement()
{
    // 円形メニューの中心座標を取得
    RectTransform m_CircleObj = this.GetComponent<RectTransform>();
    m_CenterPoint = new Vector2(m_CircleObj.position.x, m_CircleObj.position.y);

    // 半径を取得
    m_Radius = BaseObject.transform.localPosition.y;

    for (int i = 0; i < m_ButtonList.Count; i++)
    {
        // ボタンをセット
        Transform m_Trans = m_ButtonList[i].GetComponent<Transform>();
        m_AngleList.Add(i * (360.0f / m_ButtonList.Count));

        // "この"ボタンの起点となる角度°(ラジアン)
        float m_Radian = m_AngleList[i] * Mathf.Deg2Rad;

        // 座標変換
        float x = Mathf.Cos(m_Radian) * m_Radius;
        float y = Mathf.Sin(m_Radian) * m_Radius;

        // ボタンの初期位置および起点を設定
        m_Trans.localPosition = new Vector2(x, y);

        // 回転用に取得
        m_TransesList.Add(m_Trans);
        m_StartRadians.Add(m_Radian);
        m_FixedRadians.Add(m_Radian);

        // ラストの微調整用に取得
        float m_FixedX = Mathf.Cos(m_StartRadians[i]) * m_Radius;
        float m_FixedY = Mathf.Sin(m_StartRadians[i]) * m_Radius;
        m_FixedVector2_List.Add(new Vector2(m_FixedX, m_FixedY));
    }

    // ボタンを固定させるベース座標(起点位置)
    m_FixedPoint = new Vector2(m_FixedVector2_List[0].x, m_FixedVector2_List[0].y);
}          

半径取得の際の、BaseObjectは、配置するボタンのプレハブを非表示にしたものです。 円形メニューの中心点からこのプレハブの中心までの高低差 = 円周の半径となるようにしております。

これは、Unity上で円周の大きさを手動で調整できるように、このような構築にしております。

その後、1周360°をメニューに配置するボタンの数で割って均等に配置されるように計算していきます。 各ボタンの初期配置が完了したら、ボタンの起点座標を保存しておきます。

なお、全てのボタンが共通して動くため、保存しておく起点の座標は、特定の1つのボタンのみで問題ありません。

2.ドラッグして回転させる処理

public void OnDrag(PointerEventData Data)
{
    // ドラッグした座標をローカル座標に変換
    RectTransformUtility.ScreenPointToLocalPointInRectangle(
           GetComponent<RectTransform>(), Data.position, Camera.main, out m_DragPoint);

    // "ドラッグを始める前の座標"を前フレームからのベクトル(Data.delta)を使って求める
    m_BeginDragPoint = new Vector2(m_DragPoint.x - Data.delta.x,
                                        m_DragPoint.y - Data.delta.y);

    // ドラッグの距離から角度を算出
    m_Angle = Vector2.Angle(m_BeginDragPoint - m_CenterPoint,
                                        m_DragPoint - m_CenterPoint);

    // 正規化
    m_Angle *= Mathf.Sign(OnNormalized(m_BeginDragPoint - m_CenterPoint,
                                                m_DragPoint - m_CenterPoint));

    // 算出した角度をラジアンに変換
    m_Radian = m_Angle * Mathf.Deg2Rad;

    for (int i = 0; i < m_TransesList.Count; i++)
    {
        // ドラッグ前の各ラジアンを更新
        m_StartRadians[i] = m_StartRadians[i] + m_Radian;

        // ボタンの移動                
        float x = Mathf.Cos(m_StartRadians[i]) * m_Radius;
        float y = Mathf.Sin(m_StartRadians[i]) * m_Radius;

        m_TransesList[i].localPosition = new Vector2(x, y);
    }

    // ボタンが起点(ベース座標)を通過していないか判定
    // 「起点に最も近いボタン」を更新する
    if (TriggerCheck() == true) TriggerAction();

    // 「前フレームで起点(ベース座標)に最も近いボタンの座標」を更新
    CollisionCheckVector = m_TransesList[NearestNum].localPosition;
}

どれほど回転させるのかを判定するために、中点からドラッグ前・ドラッグ後それぞれの距離を使用しております。

なお、Unityではドラッグしている間、フレーム間のベクトル(どの方向にどれだけ移動したか)が保存されており、直前のフレームに限り移動量を取得することができます。

しかし、前フレームからのベクトルは取得できますが、使いたかった前フレームの座標の取得が直接できず、少し工夫が必要でした。

もし、ユーザーが画面をタッチした時点の座標を取得して用いる場合ですと、ユーザーがドラッグを開始した後、指を画面から離さず、またドラッグを開始する場合に対応できません。

そこで、「ドラッグ中のその時点の座標」と「前フレームからのベクトル」を用いて、ドラッグを始める前(前フレーム)の座標を算出することにしました。

ドラッグを始める前の座標(x, y) = 
(ドラッグ中のその時点の座標(x) - 前フレームからのベクトル(x), 
                 ドラッグ中のその時点の座標(y) - 前フレームからのベクトル(y))

次に、ドラッグする前のタッチ座標m_BeginDragPointを求め、 その後Vector2クラスのAngleという関数を用いて角度を算出します。

ただ、この関数は座標間の角度を0~180°で返すため、逆回転に対応できません。 そのため、正規化を行い符号をつけることで、逆回転にも対応できるようにしました。

算出したドラッグ角度をラジアンに変換し、用意しておいたラジアンリストに追加していきます。

最後に更新した新たなラジアン群を用いてボタンの位置を動かしていきます。

3.慣性の速度を徐々に落としていく処理

private void Update()
{
    ~~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~~~
    // ドラッグしておらずに回転している場合は慣性をつけて回す
    for (int i = 0; i < m_TransesList.Count; i++)
    {
        float x = Mathf.Cos(m_StartRadians[i] + m_Radian * 10 - m_Inertia) * m_Radius;
        float y = Mathf.Sin(m_StartRadians[i] + m_Radian * 10 - m_Inertia) * m_Radius;

        m_TransesList[i].localPosition = new Vector2(x, y);
    }
    // 慣性を(m_Speed = 0.98で)落としていく
    m_Inertia *= m_Speed;                  
    ~~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~~~
}

ここでは単純に、定めている速度で毎フレーム慣性を落としていき、ボタン位置を移動させている部分となります。

なお、ここで用いている慣性m_Inertiaは、前述のラジアンを元に算出しているため、素早くドラッグ(ラジアンの値が多い)すれば、それだけ慣性がつき、回転が速く長く続くようになります。

4.ボタン群を起点に瞬時に合わせる処理

private void OnFixedPosition_Fast()
{
    int j = NearestNum;

    for (int i = 0; i < m_FixedVector2_List.Count; i++)
    {
        m_TransesList[j].localPosition =
             new Vector2(m_FixedVector2_List[i].x, m_FixedVector2_List[i].y);
        m_StartRadians[j] = m_FixedRadians[i];

        if (j == m_FixedVector2_List.Count - 1) j = 0;
        else j++;
    }

    // 微調整終了のフラグ
    IsFixed = false;
}

NearestNum(起点に最も近いボタン番号)は、一定の慣性以下になった時点で取得できるようになっております。

そこで取得してきたNearestNumの値を用いて、その番号に対応しているボタン位置を、最初に保存していた起点ボタン位置に順番に合わせていきます。

これにより、既定の位置に、回転後のボタン群を瞬時に移動させていくことが可能となります。


上記の試作段階の仕様では、回転させたボタンの慣性が一定の速度まで落ちた瞬間にボタンが「カチッ」と止まるような挙動で、少し固いUIの印象を受けました。

実装するアプリによっては、この仕様のままでも良いのかもしれませんが、 当時開発していたアプリは、全体的に柔らかいUIが似合う印象でしたので、 最後のボタン位置の調整処理を起点に瞬時に合わせるのではなく、バウンドして起点まで合わせるように改良することにしました。

【完成した円形メニュー】

前述のボタン調整処理を改良し、完成版の仕様は以下のようになりました。

  1. ボタンを均等に自動配置する処理
  2. ドラッグして回転させる処理
  3. 慣性の速度を徐々に落としていく処理
  4. ボタン群を起点までバウンドさせる処理

f:id:Teco_Iwazaki:20200318104220g:plain

4.ボタン群を起点までバウンドさせる処理

private void OnFixedPosition_Bound()
{
    int j = NearestNum;

    for (int i = 0; i < m_FixedVector2_List.Count; i++)
    {
        m_TransesList[j].localPosition =
           Vector2.Lerp(m_TransesList[j].localPosition, m_FixedVector2_List[i], 0.1f);

        if (j == m_FixedVector2_List.Count - 1) j = 0;
        else j++;
    }
}

改善した点は、Vector2クラスのLerp関数を用いて、起点の位置からずれている各ボタンの座標を、起点座標まで滑らかに移動させるように変更しました。

微々たる違いにはなりますが、「ユーザーが気付かない = 違和感がなく自然なUI」にするのも、開発における重要なポイントなのかなと思います。

【終わりに】

本稿では、UIのもたらす印象やクオリティにおいて、「挙動」に焦点を当てて記事を書かせていただきましたが、用いる画像や色彩など、デザイン面でもこだわれる余地があると思います。 そんな、ささやかではあるものの、重要な部分についての記事は、また書ける機会があればと思います。

tecotec.co.jp