Raspberlyのブログ

Raspberlyのブログ

Unityネタをメインとした技術系ブログです。にゃんこ大戦争や日常なども。そろそろブログタイトル決めたい

【Unity】表示範囲からオーバーフローしたテキストをスクロールしてループ表示させる【TextMeshPro】

この記事はUnityAdventCalendar 2024シリーズ3の記事です。

qiita.com

記事を書く余裕が出たのと枠が空いてたので参加しました。

 

 

12/30 更新

Unity6とUnity2022後期バージョンで動かなかったのでスクリプトに修正を入れました

 

 

 

やること

表示範囲からオーバーフローしてるテキストをスクロールしてループ表示する仕組みを作成します。

ソースコードをそのままコピペするだけで動きます。

 

 

開発環境

Unity 2022.3.14f1

TextMeshPro ver3.0.9

 

 

UIの作成(動作確認用)

動作確認用のUIを作成します。

CanvasとImageの作成

シーンにCanvasを作成(CanvasScalerはお好みで)
その子にImageを作成し大きさを調整します、これが文字の表示範囲になります。

 

Imageの色は文字が見やすいように半透明の黒にしておきます

 

 

Text(TMP)の作成

Imageの子にText(TMP)を作成。

 

余白が少し欲しいのでPosXを50に。
AnchorをLeft-Middle
に、PivotのXを0に、AlignmentをLeft-Middleに設定します。

 

 

 

必要なコンポーネントの追加

RectMask2D

ImageRectMask2Dコンポーネントをアタッチ

 

これでImageの範囲外に出たTextがマスクされるようになります

 

 

ContentSizeFitter

Text(TMP)ContentSizeFitterコンポーネントをアタッチ
HorizontalFitPreferredSizeに変更


これでText(TMP)のWidthが文字数に応じて自動調整されるようになります

 

 

 

スクリプトの作成(シンプルなやつ)

ここから本題。最初はシンプルなスクリプトでやります。

 

UniTaskなどのライブラリやアセットを使っていないシンプルなやつなので、
このままコピペすればどのプロジェクトでも動作します(たぶん)

 

以下のTextScroller.csを作成。Text(TMP)にアタッチします。

 

using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

/// <summary>
/// テキストのスクロール
/// TMP_Textにアタッチしてください
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class TextScroller : MonoBehaviour
{
    [SerializeField] private float scrollSpeed = 100f; // スクロール速度
    [SerializeField] private float scrollFinishLineAddValue = 50; // スクロール終了判定までのマージン、0だと全て表示された時点で終了する
    [SerializeField] private float waitTimeBeforeScroll = 2f; // スクロール開始前の待機時間
    [SerializeField] private float waitTimeAfterScroll = 2f; // スクロール完了後、フェードアウトするまでの待機時間
    [SerializeField] private float fadeDuration = 0.4f; // テキストのフェードインアウト時間
    [SerializeField] private float waitTimeFade = 0.2f; // フェードアウトしてからフェードインするまでの待機時間
    
    private CanvasGroup canvasGroup;
    private RectTransform textRectTransform;
    private RectTransform parentRectTransform;
    private Vector3 startPosition; // スタート位置
    private float scrollValue; // スクロール値
    private float finishLineValue; // スクロールが停止する位置、scrollValueがこの値を超えたら停止する
    private float textWidth;
    private float parentWidth;
    private bool isScrolling = false;
    
    private void Start()
    {
        Initialize();
    }
    
    private void Update()
    {
        if (!isScrolling) return;
        
        // 停止位置までスクロールする
        if (scrollValue + parentWidth >= finishLineValue)
        {
            isScrolling = false;
            StartCoroutine(Fade(0, waitTimeAfterScroll));
            Invoke(nameof(ResetScrollPosition), waitTimeAfterScroll + fadeDuration + waitTimeFade);
        }
        else
        {
            scrollValue += scrollSpeed * Time.deltaTime;
            textRectTransform.anchoredPosition = new Vector3(startPosition.x - scrollValue, startPosition.y, startPosition.z);
        }
    }
    
    
    /// <summary>
    /// スクロールの初期設定
    /// </summary>
    private void Initialize()
    {
        if (!TryGetComponent<TMP_Text>(out var textComponent))
        {
            Debug.LogWarning("TMP_Textがアタッチされていないのでスクロールは機能しません");
            return;
        }
        
        // ContentSizeFitter手動更新
        var contentSizeFitter = GetComponent<ContentSizeFitter>();
        contentSizeFitter.SetLayoutHorizontal();
        contentSizeFitter.SetLayoutVertical();
        LayoutRebuilder.ForceRebuildLayoutImmediate(contentSizeFitter.GetComponent<RectTransform>());
        
        canvasGroup = GetComponent<CanvasGroup>();
        textRectTransform = textComponent.GetComponent<RectTransform>();
        parentRectTransform = textComponent.transform.parent.GetComponent<RectTransform>();
        textWidth = textRectTransform.rect.width;
        parentWidth = parentRectTransform.rect.width;
        startPosition = textRectTransform.anchoredPosition;
        finishLineValue = textWidth + startPosition.x + scrollFinishLineAddValue;
        
        if (textWidth + startPosition.x > parentWidth)
        {
            Invoke(nameof(StartScrolling), waitTimeBeforeScroll);
        }
    }
    
    
    /// <summary>
    /// スクロール開始
    /// </summary>
    private void StartScrolling()
    {
        scrollValue = 0;
        isScrolling = true;
    }
    
    
    /// <summary>
    /// スクロールリセット
    /// </summary>
    private void ResetScrollPosition()
    {
        textRectTransform.anchoredPosition = startPosition;
        StartCoroutine(Fade(1, 0));
        Invoke(nameof(StartScrolling), waitTimeBeforeScroll + fadeDuration);
    }
    
    
    /// <summary>
    /// フェード処理コルーチン
    /// </summary>
    private IEnumerator Fade(float targetAlpha, float delayTime)
    {
        var elapsedTime = 0f;
        var startAlpha = canvasGroup.alpha;
        
        yield return new WaitForSeconds(delayTime);
        while (elapsedTime < fadeDuration)
        {
            elapsedTime += Time.deltaTime;
            canvasGroup.alpha = Mathf.Lerp(startAlpha, targetAlpha, elapsedTime / fadeDuration);
            yield return null;
        }
        canvasGroup.alpha = targetAlpha;
    }
    
}

 

Text(TMP)にアタッチすると、自動的にCanvasGroupもアタッチされます。

(パラメータの詳細はソースコードのコメントから)

 

 

この状態でゲームを実行するとこんな感じ。

デフォルトのパラメータだと、実行後2秒後速度100でスクロールし、
一番最後の文字の位置+50までスクロールしたら停止、停止してから2秒後0.4秒かけてフェードアウトし、
フェードアウトしきってから0.2秒後に位置をリセットした状態で0.4秒かけてフェードイン。
以降繰り返し....

 

 

 

スクリプトの作成(UniTask + DOTWeen)

次はUniTaskDOTweenが入っているプロジェクト用のスクリプトです。

ProjectSettingsの設定

DOTweenの処理をawaitしたいので、ProjectSettingsを開き、
Player/ScriptCompilationScriptingDefineSymbolsに、UNITASK_DOTWEEN_SUPPORTを追加。

 

 

スクリプト

以下のTextScroller_UniTask.csを作成。Text(TMP)にアタッチします。

using System;
using System.Threading;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Cysharp.Threading.Tasks;
using DG.Tweening;

/// <summary>
/// テキストのスクロール
/// TMP_Textにアタッチしてください
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class TextScroller_UniTask : MonoBehaviour
{
    [SerializeField] private float scrollSpeed = 100f; // スクロール速度
    [SerializeField] private float scrollFinishLineAddValue = 50; // スクロール終了判定までのマージン、0だと全て表示された時点で終了する
    [SerializeField] private float waitTimeBeforeScroll = 2f; // スクロール開始前の待機時間
    [SerializeField] private float waitTimeAfterScroll = 2f; // スクロール完了後、フェードアウトするまでの待機時間
    [SerializeField] private float fadeDuration = 0.4f; // テキストのフェードインアウト時間
    [SerializeField] private float waitTimeFade = 0.2f; // フェードアウトしてからフェードインするまでの待機時間
    
    private CanvasGroup canvasGroup;
    private RectTransform textRectTransform;
    private Vector3 startPosition; // スタート位置
    private float finishLineValue; // スクロールが停止する位置、scrollValueがこの値を超えたら停止する
    
    private void Start()
    {
        if (!TryGetComponent<TMP_Text>(out var textComponent))
        {
            Debug.LogWarning("TMP_Textがアタッチされていないのでスクロールは機能しません");
            return;
        }
        
        // ContentSizeFitter手動更新
        var contentSizeFitter = GetComponent<ContentSizeFitter>();
        contentSizeFitter.SetLayoutHorizontal();
        contentSizeFitter.SetLayoutVertical();
        LayoutRebuilder.ForceRebuildLayoutImmediate(contentSizeFitter.GetComponent<RectTransform>());
        
        canvasGroup = GetComponent<CanvasGroup>();
        textRectTransform = textComponent.GetComponent<RectTransform>();
        var textWidth = textRectTransform.rect.width;
        var parentWidth = textComponent.transform.parent.GetComponent<RectTransform>().rect.width;
        startPosition = textRectTransform.anchoredPosition;
        finishLineValue = startPosition.x - (textWidth + startPosition.x + scrollFinishLineAddValue - parentWidth);
        if (textWidth + startPosition.x > parentWidth)
        {
            Scroll(this.GetCancellationTokenOnDestroy()).Forget();
        }
    }
    
    /// <summary>
    /// スクロール処理
    /// </summary>
    private async UniTaskVoid Scroll(CancellationToken token)
    {
        while (true)
        {
            await UniTask.Delay(TimeSpan.FromSeconds(waitTimeBeforeScroll), cancellationToken: token);
            await textRectTransform.DOAnchorPosX(finishLineValue, scrollSpeed).SetSpeedBased().SetEase(Ease.Linear).ToUniTask(cancellationToken: token);
            
            // フェードインフェードアウト
            await UniTask.Delay(TimeSpan.FromSeconds(waitTimeAfterScroll), cancellationToken: token);
            await canvasGroup.DOFade(0, fadeDuration).SetEase(Ease.Linear);
            textRectTransform.anchoredPosition = startPosition;
            await UniTask.Delay(TimeSpan.FromSeconds(waitTimeFade), cancellationToken: token);
            await canvasGroup.DOFade(1, fadeDuration).SetEase(Ease.Linear).ToUniTask(cancellationToken: token);
        }
    }
    
}

 

動作などはTextScroller.csと全く同じです。こっちの方が簡潔かも。

 

 

おまけ

もっといい感じにしたい人向け

背景をグラデーション画像にしMaskの境目を調整

Imageの画像をこんな感じのグラデーション画像に変更

 

RectMask2DのSoftnessの値を調整

 

これで、表示範囲との境界線で文字がフェードするので見栄えがちょっと良くなります

 

 

TextMeshProにRectMask2DのSoftnessが反映されない時(Unity2022用の追加対応)

※Unity6だと必要ありません

 

Unity2022、厳密にはTextMeshProのver3.0.9(Unity2022の最新バージョン)だと、
RectMask2DのSoftnessが反映されません。
こちらの記事に従い、TextMeshProを更新すれば反映されます

myudon.hatenablog.com

 

 

背景と表示範囲を分ける

確認用UIでは、ImageにRectMask2Dをつけていましたがこれを分けます。
これは後ろ側の余白をきちんと取りたい時に有効です。

 

まずImageのRectMask2Dを削除
Imageの子に、空のGameObjectを作成し名前を「Mask」に変更。
このMaskにRectMask2Dコンポーネントをアタッチします。

 

 

 

 

 

 

Unity 6用の修正(12/30)

Unity6、および2022の後期バージョンだと発生する問題がありました。
これらでは、ContentSizeFitterのサイズ調整が即座に行われず、
1フレーム待たないと正常なWidthの値が取れない状態でした。

 

そこで上記のスクリプトに以下のコードを入れて対応しました。
これを呼び出すと即座にサイズが更新されます。

using UnityEngine.UI;

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

// ContentSizeFitter手動更新
var contentSizeFitter = GetComponent<ContentSizeFitter>();
contentSizeFitter.SetLayoutHorizontal();
contentSizeFitter.SetLayoutVertical();
LayoutRebuilder.ForceRebuildLayoutImmediate(contentSizeFitter.GetComponent<RectTransform>());

 

 

 

過去のUnity アドベントカレンダー記事

raspberly.hateblo.jp

raspberly.hateblo.jp

raspberly.hateblo.jp

 

 

参考資料

zenn.dev

kan-kikuchi.hatenablog.com

 

 

 

 

以上です