UI Toolkit - Text Best Fit

Is there a way to make the text fit the container (by automatically adjusting its font) using the new UI Toolkit and UI Builder?

Thank you @andrew-lukasik, I made a new script following your steps that seems to fit more properly to my needs. It is not perfect though as it doesn’t refresh properly in a few cases. Any improvements are welcome.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;

public class LabelAutoFit : Label
{
    [UnityEngine.Scripting.Preserve]
    public new class UxmlFactory : UxmlFactory<LabelAutoFit, UxmlTraits> { }

    [UnityEngine.Scripting.Preserve]
    public new class UxmlTraits : Label.UxmlTraits
    {
        readonly UxmlIntAttributeDescription minFontSize = new UxmlIntAttributeDescription
        {
            name = "min-font-size",
            defaultValue = 10,
            restriction = new UxmlValueBounds {min = "1"}
        };

        readonly UxmlIntAttributeDescription maxFontSize = new UxmlIntAttributeDescription
        {
            name = "max-font-size",
            defaultValue = 200,
            restriction = new UxmlValueBounds {min = "1"}
        };

        public override IEnumerable<UxmlChildElementDescription> uxmlChildElementsDescription { get { yield break; } }

        public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
        {
            base.Init(ve, bag, cc);

            LabelAutoFit instance = ve as LabelAutoFit;
            instance.minFontSize = Mathf.Max(minFontSize.GetValueFromBag(bag, cc), 1);
            instance.maxFontSize = Mathf.Max(maxFontSize.GetValueFromBag(bag, cc), 1);
            instance.RegisterCallback<GeometryChangedEvent>(instance.OnGeometryChanged);
            instance.style.fontSize = 1; // Triggers OnGeometryChanged callback
        }
    }

    // Setting a limit of max text font refreshes from a single OnGeometryChanged to avoid repeating cycles in some extreme cases
    private const int MAX_FONT_REFRESHES = 2;

    private int m_textRefreshes = 0;

    public int minFontSize { get; set; }
    public int maxFontSize { get; set; }

    // Call this if the font size does not update by just setting the text
    // Should probably wait till the end of frame to get the real font size, instead of using this method
    public void SetText(string text)
    {
        this.text = text;
        UpdateFontSize();
    }

    private void OnGeometryChanged(GeometryChangedEvent evt)
    {
        UpdateFontSize();
    }

    private void UpdateFontSize()
    {
        if (m_textRefreshes < MAX_FONT_REFRESHES)
        {
            Vector2 textSize = MeasureTextSize(text, float.MaxValue, MeasureMode.AtMost, float.MaxValue, MeasureMode.AtMost);
            float fontSize = Mathf.Max(style.fontSize.value.value, 1); // Unity can return a font size of 0 which would break the auto fit // Should probably wait till the end of frame to get the real font size
            float heightDictatedFontSize = Mathf.Abs(contentRect.height);
            float widthDictatedFontSize = Mathf.Abs(contentRect.width / textSize.x) * fontSize;
            float newFontSize = Mathf.FloorToInt(Mathf.Min(heightDictatedFontSize, widthDictatedFontSize));
            newFontSize = Mathf.Clamp(newFontSize, minFontSize, maxFontSize);
            if (Mathf.Abs(newFontSize - fontSize) > 1)
            {
                m_textRefreshes++;
                style.fontSize = new StyleLength(new Length(newFontSize));
            }
        }
        else
        {
            m_textRefreshes = 0;
        }
    }
}

This is what I’m using. It’s pretty simple and it works well for me. It basically works by first trying to find the font size that will fill the width (which could result is a very large font size that exceeds the height) then the size is clamped by the parent height. The LabelAutoFit element can be scaled with the transform to get a relative size to the parent.
204200-labelautofit.gif

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;

public class LabelAutoFit : Label
{
  [UnityEngine.Scripting.Preserve]
  public new class UxmlFactory : UxmlFactory<LabelAutoFit, UxmlTraits> { }

  [UnityEngine.Scripting.Preserve]
  public new class UxmlTraits : Label.UxmlTraits
  {
    public override IEnumerable<UxmlChildElementDescription> uxmlChildElementsDescription { get { yield break; } }

    public override void Init(VisualElement visualElement, IUxmlAttributes attributes, CreationContext creationContext)
    {
      base.Init(visualElement, attributes, creationContext);
    }
  }

  public LabelAutoFit()
  {
    RegisterCallback<AttachToPanelEvent>(OnAttachToPanel);
  }

  private void OnAttachToPanel(AttachToPanelEvent e)
  {
    UnregisterCallback<AttachToPanelEvent>(OnAttachToPanel);
    RegisterCallback<GeometryChangedEvent>(OnGeometryChanged);
  }

  private void OnGeometryChanged(GeometryChangedEvent e)
  {
    UpdateFontSize();
  }

  private void UpdateFontSize()
  {
    UnregisterCallback<GeometryChangedEvent>(OnGeometryChanged);
    var previousWidthStyle = style.width;

    try
    {
      var width = resolvedStyle.width;

      // Set width to auto temporarily to get the actual width of the label
      style.width = StyleKeyword.Auto;
      var currentFontSize = MeasureTextSize(text, 0, MeasureMode.Undefined, 0, MeasureMode.Undefined);

      var multiplier = resolvedStyle.width / Mathf.Max(currentFontSize.x, 1);
      var newFontSize = Mathf.RoundToInt(Mathf.Clamp(multiplier * currentFontSize.y, 1, resolvedStyle.height));

      if (Mathf.RoundToInt(currentFontSize.y) != newFontSize)
        style.fontSize = new StyleLength(new Length(newFontSize));
    }
    finally
    {
      style.width = previousWidthStyle;
      RegisterCallback<GeometryChangedEvent>(OnGeometryChanged);
    }
  }
}

_
Here is my half-working prototype for a custom Label element that attempts to solve exactly that.
_
alt text
_
It’s incomplete and with potential bugs (just a prototype), also with a requirement that you do not use flex grow (it produces undefined behaviour (recursion)) and use Size/Width [%] or Height [%] instead.

// src* = https://gist.github.com/andrew-raphael-lukasik/8f65a4d7055e29f80376bcb4f9b500af
using UnityEngine;
using UnityEngine.UIElements;

// IMPORTANT NOTE:
// This elemeent doesn't work with flexGrow as it leads to undefined behaviour (recursion).
// Use Size/Width[%] and Size/Height attributes</b> instead

[UnityEngine.Scripting.Preserve]
public class LabelAutoFit : UnityEngine.UIElements.Label
{

	public Axis axis { get; set; }
	public float ratio { get; set; }

	[UnityEngine.Scripting.Preserve]
	public new class UxmlFactory : UxmlFactory<LabelAutoFit,UxmlTraits> {}
	
	[UnityEngine.Scripting.Preserve]
	public new class UxmlTraits : Label.UxmlTraits// VisualElement.UxmlTraits
	{
		UxmlFloatAttributeDescription _ratio = new UxmlFloatAttributeDescription{
			name = "ratio" ,
			defaultValue = 0.1f ,
			restriction = new UxmlValueBounds{ min="0.0" , max="0.9" , excludeMin=false , excludeMax=true }
		};
		UxmlEnumAttributeDescription<Axis> _axis = new UxmlEnumAttributeDescription<Axis>{
			name = "ratio-axis" ,
			defaultValue = Axis.Horizontal
		};
		public override void Init ( VisualElement ve , IUxmlAttributes bag , CreationContext cc )
		{
			base.Init( ve , bag , cc );

			LabelAutoFit instance = ve as LabelAutoFit;
			instance.RegisterCallback<GeometryChangedEvent>( instance.OnGeometryChanged );

			instance.ratio = _ratio.GetValueFromBag( bag , cc );
			instance.axis = _axis.GetValueFromBag( bag , cc );
			instance.style.fontSize = 1;// triggers GeometryChangedEvent
		}
	}

	void OnGeometryChanged ( GeometryChangedEvent evt )
	{
		float oldRectSize = this.axis==Axis.Vertical ? evt.oldRect.height : evt.oldRect.width;
		float newRectLenght = this.axis==Axis.Vertical ? evt.newRect.height : evt.newRect.width;
		
		float oldFontSize = this.style.fontSize.value.value;
		float newFontSize = newRectLenght * this.ratio;
		
		float fontSizeDelta = Mathf.Abs( oldFontSize - newFontSize );
		float fontSizeDeltaNormalized = fontSizeDelta / Mathf.Max(oldFontSize,1);

		if( fontSizeDeltaNormalized>0.01f )
			this.style.fontSize = newFontSize;
	}

	public enum Axis { Horizontal , Vertical }

}

If sb knows how to improve on that, please do share your code too.

I got this to adjust size in the UI Builder. However when I play in the game view using a mobile simulator this does not size properly.

I suggest to increase/decrease the fontSize in a loop until MeasureTextSize fits into the bounds of contentRect.

Further, I suggest to make it a common class instead of a VisualElement, such that it can be used with existing Label elements.

 using UnityEngine;
 using UnityEngine.UIElements;

 public class AutoFitLabelControl
 {
     public float MinFontSizeInPx { get; set; }
     public float MaxFontSizeInPx { get; set; }
     public int MaxFontSizeIterations { get; set; } = 20;

     private readonly Label labelElement;

     public AutoFitLabelControl(Label labelElement, float minFontSizeInPx = 10, float maxFontSizeInPx = 50)
     {
         this.labelElement = labelElement;
         this.MinFontSizeInPx = minFontSizeInPx;
         this.MaxFontSizeInPx = maxFontSizeInPx;
         this.labelElement.RegisterCallback<GeometryChangedEvent>(evt => UpdateFontSize());
         this.labelElement.RegisterValueChangedCallback(evt => UpdateFontSize());
     }

     public void UpdateFontSize()
     {
         if (float.IsNaN(labelElement.contentRect.width)
             || float.IsNaN(labelElement.contentRect.height))
         {
             // Cannot calculate font size yet.
             return;
         }

         float nextFontSizeInPx;
         int direction;
         int lastDirection = 0;
         float step = 1;
         int loop = 0;

         while (loop < MaxFontSizeIterations)
         {
             Vector2 preferredSize = labelElement.MeasureTextSize(labelElement.text,
                 0, VisualElement.MeasureMode.Undefined,
                 0, VisualElement.MeasureMode.Undefined);

             if (preferredSize.x > labelElement.contentRect.width
                 || preferredSize.y > labelElement.contentRect.height)
             {
                 // Text is too big, reduce font size
                 direction = -1;
             }
             else
             {
                 // Text is too small, increase font size
                 direction = 1;
             }

             if (lastDirection != 0
                 && direction != lastDirection)
             {
                 // Found best match.
                 return;
             }
             lastDirection = direction;

             nextFontSizeInPx = labelElement.resolvedStyle.fontSize + (step * direction);
             nextFontSizeInPx = NumberUtils.Limit(nextFontSizeInPx, MinFontSizeInPx, MaxFontSizeInPx);
             labelElement.style.fontSize = nextFontSizeInPx;
             loop++;
         }
     }
 }