6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

隅っこにあるようなUIでも実装してみると考えることが多いという話

6
Last updated at Posted at 2025-12-18

QualiArts Advent Calendar 2025 の18日目の記事になります。
株式会社QualiArtsでUnityエンジニアをしています片岡です。

UIは日常的に目にするものです。
一見簡単に実装できそうでも意外と考えて実装しないといけないUIもあります。
本記事では隅っこにあるようなUIを例にして考えるべきことをさっと散りばめました。
UnityのuGUIにおけるUI実装始めたての方を主なターゲットにしていますが、なにか思い当たって参考になるものが見つかれば幸いです!

はじめに

今回紹介するUIはこちらの切り替えボタンです。
UnityのuGUIで実装しています。

Kapture 2025-12-18 at 05.38.50.gif

  • テキスト領域をタップでバーの位置を切り替え
  • テキスト領域は文字数に応じて変わる
  • テキスト領域に応じてバーの幅も変わる

という仕様です。
どこかで見たことあるような隅っこにあるようなUIです。
見た目ではわかりませんが、テキストと数は動的に設定可能になっています。

このUIをどうやって実装するかイメージが湧きますか?
実装してみると意外と考えるべきことが多いです。
考えるべきことが多い分、UI実装始めたての人にとって学べることが多そうだなと思ったので今回記事にすることにしました。

ざっくりとした実装の手順を紹介していき、考えることの多さもお伝えできればと思います。

TextMesh ProとDOTween、UniTaskを利用します。
導入方法については以下がとても参考になります。

レイアウトについて

必要なものは

  • UIの領域を示す背景
  • バー
  • テキスト付きボタン

の3つになります。

テキスト付きボタンは一つのプレハブにしてテキストとボタンの機能を予めまとめておきます。
image.png

また、スクリプトから任意の文字列と数を動的に指定できるようにHorizontal Layout Groupを用いています。また、Content Size Fitterも用いることでUI自体の領域が広がるようにしています。
image.png
image.png

TextMesh ProのコンポーネントがLayout Elementの機能を持つため、Horizontal Layout GroupでControl Child SizeのWidthとHeightを設定してあげることでテキスト付きのボタンの領域が文字数によって変動し、整列するようになります。
テキストの間隔はHorizontal Layout GroupのPaddingとSpacingを設定するか、もしくはTextMesh ProのコンポーネントのMarginsから設定すると良いかと思います。

バーはテキスト付きボタンのanchoredPositionに移動させて切り替えを実現します。
テキスト付きボタンのアンカーがHorizontal Layout Groupによって左上端になるので、バーのアンカーも同様に左上端にしておきます。

image.png

Horizontal Layout Groupの直下にUIの領域を示す背景とバーを置いています。
両者にはIgnore LayoutをオンにしたLayout Elementコンポーネントをアタッチし、自動レイアウトの対象外になるようにしています。
背景はアンカーを縦横ストレッチにすることでContent Size Fitterによって広がったUIの領域にフィットするようにしています。
バーはテキストの後に表示する必要があり、ヒエラルキー順的にテキスト付きボタンよりも上になければならないので、ここに置いています。

image.png

コードについて

まずはテキスト付きボタンのクラスです。

using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class TextButton : UIBehaviour
{
    [SerializeField] private TextMeshProUGUI _text;

    [SerializeField] private Button _button;

    public Button Button => _button;

    public void SetText(string text)
    {
        _text.text = text;
    }
}

もう一つはUIのクラスです。

using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using DG.Tweening;
using DG.Tweening.Core;
using UnityEngine.UI;

public class NavigationBar : MonoBehaviour
{
    [SerializeField] private string[] _texts;

    [SerializeField] private TextButton _textButtonPrefab;

    [SerializeField] private RectTransform _bar;

    [SerializeField] private int _initialIndex = 0;

    private List<TextButton> _buttons = new();

    private void Awake()
    {
        foreach (var text in _texts)
        {
            var textButton = Instantiate(_textButtonPrefab, transform);
            _buttons.Add(textButton);
            textButton.SetText(text);
            textButton.Button.onClick.AddListener(() => OnClick(textButton));
        }

        SelectAsync(gameObject.GetCancellationTokenOnDestroy()).Forget();

        async UniTask SelectAsync(CancellationToken ct)
        {
            // LayoutGroupによるレイアウト自動更新後にanchoredPositionやSizeが正しく取れるようにする
            await UniTask.Yield(ct);
            LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform)transform);
            Select(_initialIndex, isImmediate: true);
        }
    }

    private void Select(int index, bool isImmediate = false)
    {
        OnClick(_buttons[index], isImmediate);
    }

    private void OnClick(TextButton button, bool isImmediate = false)
    {
        var buttonRectTransform = (RectTransform)button.transform;

        const float Duration = 0.3f;
        var fromSizeX = _bar.rect.width;
        var endSizeX = buttonRectTransform.rect.width;
        var endPositionX = buttonRectTransform.anchoredPosition.x;

        if (isImmediate)
        {
            _bar.SetAnchoredPositionX(endPositionX);
            _bar.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, endSizeX);
            return;
        }

        DOTween.Sequence()
            .Join(_bar.DOAnchorPosX(endPositionX, Duration))
            .Join(_bar.DoSizeWithCurrentAnchors(RectTransform.Axis.Horizontal,
                getter: () => fromSizeX, endSizeX, Duration))
            .SetEase(Ease.OutQuad)
            .SetLink(gameObject);
    }
}

public static class RectTransformExtensions
{
    public static Tweener DoSizeWithCurrentAnchors(this RectTransform target, RectTransform.Axis axis,
        DOGetter<float> getter, float endValue, float duration)
    {
        return DOTween.To(getter, x => target.SetSizeWithCurrentAnchors(axis, x), endValue, duration);
    }

    public static void SetAnchoredPositionX(this RectTransform rectTransform, float x)
    {
        var anchoredPosition = rectTransform.anchoredPosition;
        anchoredPosition.x = x;
        rectTransform.anchoredPosition = anchoredPosition;
    }
}

SerializeFieldの_textsで任意のテキストと数が指定できるようになっています。

Awake()でInstantiateしたテキスト付きボタンにテキストとクリック時のイベントを設定します。
Horizontal Layout GroupによるTextMesh Proのテキスト自動レイアウトはAwake()の時点では見た目のレイアウトは確定できておらず、最初のバーの位置を決める処理ができないため、1フレームの待機LayoutRebuilder.ForceRebuildLayoutImmediate()を実行することで最速で初期選択状態のUIを表示できるようになっています。

クリック時にバーの位置と大きさを変えます。
rectの大きさはSetSizeWithCurrentAnchors()で変えています。
RectTransformのsizeDeltaで変えるというやり方を見かけますが、sizeDeltaは変動しうるアンカーとrectとの間の距離です。rectの大きさ≠sizeDeltaなので、わからないままsizeDeltaを変えるのは非推奨です。

RectTransformExtentionsクラスには今回使った(業務でもよく使う)便利メソッドも用意しています。

最後に

何気なく利用するUIは色々と考えて作られていることがあります。
アンカーや自動レイアウト周りは特に考えることが多くなりがちです。
本記事を見て何か発見したり、少しでもUI実装に興味や関心を持っていただけると幸いです!

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?