Unity Mobile Text Input Sucks: This Is How I Got Around It

Unity's text input doesn't behave like a real text field on a phone, so I put a native one on top of it. Select, copy, and paste worked, but I had to deal with frame desyncs, ghosting letters, a keyboard loop, and fonts that pushed us into Google Play's 300 MB limit.

Unity Mobile Text Input Sucks: This Is How I Got Around It
Photo by freestocks / Unsplash

When Unity can't handle the stress of a real text field, what do you do? Simple: ignore Unity's input.

I found a package that renders native inputs for iOS and Android right on top of Unity. There's a catch, though. It didn't work out of the box. Every problem it solved handed me a smaller one to solve myself.

Why Unity's input wasn't good enough

Unity draws its own UI. That sounds obvious until you think about what it means for text: a TextMesh Pro (TMP) input field isn't a text field as far as the phone is concerned. It's a Unity object that looks like one.

So the phone doesn't treat it like a text field. No select, no copy, no paste, no magnifier on long press, none of the editing behavior every other app on the device gets for free. You can reimplement some of it, but you won't match it.

On top of that, the Unity-rendered input didn't behave the same on iOS and Android, so we were tuning the same field twice and still landing somewhere that felt off.

For a form a user fills out once, maybe that's fine. For an app, user experience must be absolutely seamless, it isn't. The project that ran into this demanded excellent user experience, and text input is where a lack of polish showed up first.

The fix: let the phone draw the field

I used Unity Mobile Input, UMI for short, an open-source package by mopsicus.

UMI copies your font over to the native side, then renders a real native input field with no visible chrome of its own, positioned right on top of the Unity field. The user taps what looks like a Unity text field, but what actually takes the tap is a platform text field.

Select, copy, paste, and the rest of the native editing behavior come along for free, which was the whole reason to do it.

Now the catch. A native view sitting on top of a Unity scene means you own the seam between two things that don't share a clock, and every problem I hit came out of that seam.

Problem 1: everything is a frame off

One of the input fields I worked on zoomed in and out when it opened and closed, and during those animations the Unity scene and the native input drifted apart. One would lag behind the other pretty consistently by a frame or two.

That's the seam. Unity's code execution and frame rendering aren't synced with the native view's updates, so unless the timing happens to line up, something is always slightly behind.

I could have spent a long time chasing that timing, but instead, I took a shortcut. I disabled the native input before a positional animation starts and re-enabled it once the animation finishes. The animation plays entirely in Unity, with nothing native on top of it to fall behind.

If the field looks right sitting still but ghosts or smears while it moves, you're looking at the seam, not at your animation curve.

This only applied to transformation-based animations, so the field was moving, rotating, or scaling. This wasn't needed for color or other similar animations. I built it so that the animations were automatically tracked by watching for changes in the transform of the TMP object. If they are different from the last frame, disable UMI. When they're the same, enable it again.

Always looks for ways to make common work automatic. Don't force yourself to wire up everything everytime.

At this point, I thought I was done. However, this solution also set up the next two problems. The moment you turn the native field off, something else has to be visible in its place.

Problem 2: ghosting letters

Turning on the UMI input doesn't always do anything to the Unity input underneath it. Both are live, and both draw text.

Most of the time you don't notice, because UMI positions itself to match and the two overlap exactly. Change the font, or force one, and the alignment breaks, so you see double letters.

I built a wrapper that owns both fields and treats them as one control. When the UMI input is on, the wrapper turns off the visuals on the TMP input. Only the text is hidden, and the background stays up the whole time, so the field itself never flickers and whatever UMI is rendering is always what the user sees.

The one-frame problem shows up here too. If you hide one field on exactly the frame you show the other, you can land on a frame with no text at all. The enable and disable have to overlap by at least a frame, which means both fields are briefly drawing text together, which means the alignment has to be right.

In practice it almost always was, but wrapped text and broken fonts were the exception, since the two fields could end up misaligned.

Problem 3: the keyboard opened, and the field closed itself

We moved the inputs up and down when the keyboard opened and closed, so the field being typed into wasn't hiding behind the keyboard.

That movement is a positional animation, so it triggered the fix from Problem 1, and that produced a loop:

  1. The user selects the input, and the keyboard opens.
  2. The keyboard opening triggers the reposition animation, so the native input gets disabled.
  3. With the native input gone, the keyboard closes.
  4. The keyboard closing triggers another reposition animation, and the native input comes back afterward.
  5. Back to step 1.

The first fix was too blunt. Turning the native field off is fine when the animation has nothing to do with focus, but not when the animation was caused by the keyboard that the native field is holding open.

So we stopped disabling UMI for this case. It stays enabled and keeps focus, and we hide the text it renders until the animation finishes. The TMP text underneath is already showing, so the user sees continuous text while the field moves.

If selecting a field makes the keyboard flash open and shut, look at what your animation handling does to focus before you look at the animation.

Cursor position

This one is smaller, but worth mentioning. The cursor position has to be translated from UMI over to TMP and back, or the user closes an input, opens it again, and the caret is somewhere they didn't leave it. Nobody files a bug for that, but they do walk away feeling like the app is sloppy.

Problem 4: 300 MB of fonts

The app was meant to be used globally, so it had to accept text in a lot of languages.

I shipped font files with the app and registered them with the device, which lets UMI use them for input. Because UMI required the original font file, we were doubling our font data. TMP creates its own render texture, and we have the original font it was based on.

We used Google's Noto Sans, a family that covers most of Unicode, and included character sets well past English.

That works, but it costs you. The fonts aren't huge on their own, but enough of them together pushed us into Google Play's 300 MB download limit. On-device size was never the issue for us, but download size was.

We never solved that on the original project. Instead, we admitted that this was supposed to be a first iteration and focused on the most common languages. We marked the rest as technical debt.

You can see when a font is missing as most devices have at least a fallback font for Unicode characters. In the Unity Rendered items, you'll see the squre 'I can't render this' icon, but in the inputs, you'll see the text in a different font.

If I were doing it again, I'd make the fonts downloadable instead of bundled, either by letting the user pick the languages they need or by detecting the language and fetching fonts after install. Either one works. The part I'd want to design carefully is what a user sees when they type in a language whose font hasn't arrived yet, because that's where the cost goes once you take it out of the download.

Maintenance notes

UMI's last release was about two years ago, and my project came shortly after it, so what I ran into should be what you'd get today if using older versions of Unity.

These were workarounds for the Text Mesh Pro inputs that should be fixed one day. Maybe it'll be in Unity 6? Maybe it won't. Until then, this is a solution.

However, UMI's age means it isn't staying up to date. A package that doesn't change won't break your build, but it also isn't tracking two mobile platforms that do change. If you're picking this up for something long-lived, budget time to actually read the package instead of treating it as a black box.

I should also say that this work was done for a client, so the code isn't mine to share. What's here is the approach and the problems, not a drop-in solution.

Was it worth it?

Yes, for this app.

Native input in Unity isn't one problem. It's the input problem, which a package solves, and then a rendering seam, which is yours to manage. What made the seam manageable was picking a rule early: the native field is the source of truth whenever it's there, and the Unity field's job is to be a convincing stand-in when it isn't. Most of the fixes above are that rule applied to a specific frame.

It's not the cheap option, but if typing is central to your app and it has to feel like the rest of the phone, it's the right trade.