08 August 2018

Auto word wrapping a text to fit before a backdrop pane in Mixed Reality apps

Intro

As Mixed Reality in general and HoloLens apps in particular become more mainstream, so they become ever more complex, and this reflects in their interfaces. And if, in those user interfaces, we need to communicate something more complex to user, text is in most cases still the most efficient way of doing that.

Now a floating text by itself may not be very readable, so you might want to use some kind of backdrop. I usually take a kind of blueish background with white text, as that turns out the most readable. And yeah, I know, it feels a bit 2D Windows-ish – but until someone comes up with a better paradigm, it will have to do.

A TextMesh in Unity does not have a clue about word wrapping or fitting into a specific 'box' – you need to do that yourself. There is some of that in the Mixed Reality Toolkit dialog system – but that wraps based on the maximum of characters. If you change the font size, or the size of the backdrop – you will need to start testing again if you message fits. No need for that here.

The actual text wrapping

public static class TextUtilities
{
    public static void WordWrap(this TextMesh mesh, float maxLength)
    {
        var oldQ = mesh.gameObject.transform.rotation;
        mesh.gameObject.transform.rotation = Quaternion.identity;
        var renderer = mesh.GetComponent<Renderer>();
        var parts = mesh.text.Split(' ');
        mesh.text = "";
        foreach (var t in parts)
        {
            var builder = new StringBuilder(mesh.text);

            mesh.text = string.Concat(mesh.text, t, " ");
            if (renderer.bounds.size.x > maxLength)
            {
                builder.AppendFormat("{0}{1} ", Environment.NewLine, t);
                mesh.text = builder.ToString();
            }
        }

        mesh.text = mesh.text.TrimEnd();
        mesh.gameObject.transform.rotation = oldQ;
    }
}

This sits in an extension method in the class TextUtilities.It assumes the text has already been applied to the text mesh. What is basically does is:

  • Rotate the text mesh to Identity so it can measure width in one plane
  • Split the text in words
  • For each word:
    • Make a StringBuilder for the text so far
    • Add the word to the mesh
    • Calculate the width of the resulting mesh
    • If the mesh is wider than allowed:
      • Add the word to the StringBuilder with a newline prepending it
      • Set mesh to the StringBuilder’s contents

Now I did not make that up myself, I nicked it from here in the Unity Forums but I kind of simplified and optimized it a bit – a thing I am prone to doing as my colleague knows ;)

Calculating the size

As I have written in a previous post, you can calculate an object's size by getting the Render's size. But I also shown what you get is the size after rotation. So what you need to do is to calculate the unrotated size. I put the same trick as used in the way to measure the text width in an extension method:

public static class GameObjectExtensions
{
    public static Vector3 GetRenderedSize( this GameObject obj)
    {
        var oldQ = obj.transform.rotation;
        obj.transform.rotation = Quaternion.identity;
        var result = obj.GetComponent<Renderer>().bounds.size;
        obj.transform.rotation = oldQ;
        return result;
    }
}

Rotate the object to identity, get it's render's size, return the object back, then return the result. A rather crude way to get to the size, but it seems to work. I stuck this method into my GameObjectExtensions class.

Connecting all the dots

The only thing now missing is a behaviour that will be using all this:

public class SimpleTextDialogController : MonoBehaviour
{
    private TextMesh _textMesh;

    [SerializeField]
    public GameObject _backPlate;

    [SerializeField]
    public float _margin = 0.05f;

    void Start()
    {
        _textMesh = GetComponentInChildren<TextMesh>();
        gameObject.SetActive(false);
    }

    public void ShowDialog(string text)
    {
        gameObject.SetActive(true);
        StartCoroutine(SetTextDelayed(text));
    }

    private IEnumerator SetTextDelayed(string msg)
    {
        yield return new WaitForSeconds(0.05f);
        _textMesh.text = msg;
        var sizeBackplate = _backPlate.GetRenderedSize();
        var textWidth = sizeBackplate.x - _margin * 2f;
        _textMesh.WordWrap(textWidth);
        _textMesh.GetComponent<Transform>().position -= new Vector3(textWidth/2f, 0,0);
    }
}

So the start method immediately renders the game object invisible. Calling the ShowDialog makes the 'dialog' visible and actually sets the text, by calling the ShowTextDelayed coroutine, where the stuff is actually happening. First we get the size of the 'backplate', then we calculate the desired width of the text. After that the text is wordwrapped, and then it's moved half the calculated width to the left.

So why the delay? This is because the complete dialog looks like this:

image

I reuse the AdvancedKeepInViewController from my previous post. But if you use the AppearInView property (as I do), that places the dialog's center exactly on the gaze cursor when it's activated. And you will see that it tends to appear on the left of the center, then quickly move to the center.

That is because when the text is rendered without the word wrap, it looks like this

image

So Unity calculates the horizontal component of the center of all the objects in the combined game object, that end up a little left of the center of the text. But what we want to see is this:

image

The text is nicely wrapped and fits before the box. So hence the little delay, to allow AdvancedKeepInViewController to calculate and place the object in the center, before we start messing with the text.

Finally there's a simple behaviour called DialogLauncher but basically all that does is calling the ShowDialog method with some text I got from "Startupsum", a kind of Lorum Ipsum generator that uses words from your average Silicon Valley startup marketing manager's BS jargon.

The fun thing is that when you make the dialog bigger and run the app again, it will automatically word wrap to the new size:

image

And when you increase the font size:

image

Requirements and limitations

There are four requirements to the text:

  • The text needs to be placed in the horizontal center of the backdrop plate.
  • Vertically it needs to be in the position where you want to the text to start flow down from
  • imageIt needs to have it's Anchor upper left
  • The Alignment needs to be left

Limitations

  • If you have a single word in your text that's so long it takes up more space than available, you are out of luck. Germans need to be especially careful ;)
  • As you can see a little in the last image: if the text is so long it takes up more vertical space than available, Unity will render the rest 'in thin air' under the backdrop.

It's actually possible to fix that too, but you might wonder how efficiently you are communicating if you need to write large texts in what is in essence an immersive experience. How much people read an entire six-paragraph text in zoo or a museum? "Brevity is the soul of wit", as The Bard said, so keep texts short and concise.

Conclusion

Thank to Shopguy on the Unity Forums for the original word wrapping code. The code for this article can be found here.

No comments: