Unity Mobile Touch Controls: Virtual Joystick, Buttons and Gestures

Illustration of a phone with a virtual joystick

Mobile games live or die by their controls. A virtual joystick that drifts, buttons that don’t respond to a second finger, or a camera swipe that fires when you tap a button will get your game uninstalled in minutes. This guide covers Unity mobile touch controls with the Input System: reading touches, a floating virtual joystick, on-screen buttons, swipe and pinch gestures, and testing without deploying to a phone every time.

Input System Setup for Touch

Touch support comes from the Input System package (the default for new Unity 6 projects). Check Project Settings > Player > Active Input Handling is set to Input System Package (New) or Both. If the Input System is new to you, start with our Input System beginner’s guide.

Make sure your scene’s EventSystem uses the Input System UI Input Module, not the old Standalone Input Module. Unity offers a button to replace it. Otherwise UI buttons ignore touches.

On-Screen Stick and Button (the Fastest Way)

The Input System includes two components that pretend to be a gamepad. That means your existing actions (bound to Gamepad Left Stick and Gamepad Button South) work on touch with zero new gameplay code:

  1. Create a UI Image for the stick handle, inside a background circle. Add On-Screen Stick to the handle and set Control Path to <Gamepad>/leftStick, with Movement Range around 50.
  2. Create a UI Button-style Image. Add On-Screen Button and set Control Path to <Gamepad>/buttonSouth.
  3. Make sure your Move action has a Gamepad Left Stick binding and Jump has Button South.

That’s a working mobile control scheme in five minutes. The limitation is that the stick is fixed in place, and players increasingly expect a joystick that appears where the thumb lands. On-Screen Stick also has a Behaviour: Isolated Input Actions option, which helps when it clashes with other pointer input.

Reading Touches With EnhancedTouch

For custom controls, use the EnhancedTouch API. It tracks each finger over its whole lifetime and makes multi-touch easy:

using UnityEngine;
using UnityEngine.InputSystem.EnhancedTouch;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;

public class TouchDebug : MonoBehaviour
{
    void OnEnable()  => EnhancedTouchSupport.Enable();
    void OnDisable() => EnhancedTouchSupport.Disable();

    void Update()
    {
        foreach (Touch touch in Touch.activeTouches)
        {
            Debug.Log($"Finger {touch.finger.index}: {touch.phase} at {touch.screenPosition}");
        }
    }
}

The using Touch = ... alias avoids ambiguity with the old UnityEngine.Touch struct. Phases are Began, Moved, Stationary, Ended and Canceled. Track a specific finger with touch.finger rather than array index, since indices shift when other fingers lift.

A Floating Virtual Joystick

This joystick appears wherever a finger first touches the left half of the screen, and outputs a Vector2 you can feed into movement:

using UnityEngine;
using UnityEngine.InputSystem.EnhancedTouch;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;

public class FloatingJoystick : MonoBehaviour
{
    [SerializeField] RectTransform background, handle;
    [SerializeField] float radius = 100f;   // in canvas units
    [SerializeField] Canvas canvas;

    public Vector2 Value { get; private set; }
    Finger activeFinger;
    Vector2 startPos;

    void OnEnable()
    {
        EnhancedTouchSupport.Enable();
        Touch.onFingerDown += OnDown;
        Touch.onFingerMove += OnMove;
        Touch.onFingerUp += OnUp;
        background.gameObject.SetActive(false);
    }

    void OnDisable()
    {
        Touch.onFingerDown -= OnDown;
        Touch.onFingerMove -= OnMove;
        Touch.onFingerUp -= OnUp;
        EnhancedTouchSupport.Disable();
    }

    void OnDown(Finger f)
    {
        if (activeFinger != null || f.screenPosition.x > Screen.width * 0.5f) return;
        activeFinger = f;
        startPos = f.screenPosition;
        background.position = startPos;
        handle.anchoredPosition = Vector2.zero;
        background.gameObject.SetActive(true);
    }

    void OnMove(Finger f)
    {
        if (f != activeFinger) return;
        float scaledRadius = radius * canvas.scaleFactor;
        Vector2 delta = Vector2.ClampMagnitude(f.screenPosition - startPos, scaledRadius);
        handle.anchoredPosition = delta / canvas.scaleFactor;
        Value = delta / scaledRadius;
    }

    void OnUp(Finger f)
    {
        if (f != activeFinger) return;
        activeFinger = null;
        Value = Vector2.zero;
        background.gameObject.SetActive(false);
    }
}

Multiplying by canvas.scaleFactor keeps the radius consistent across screen sizes when the Canvas Scaler is in Scale With Screen Size mode (see our UI scaling guide). Add a small dead zone (ignore Value.magnitude < 0.1f) to stop drift from a resting thumb.

Swipe, Tap and Pinch Gestures

Tap vs swipe: on finger up, measure distance and duration from finger down. Short and small is a tap. Long and fast is a swipe:

const float SwipeMinPixels = 80f, TapMaxTime = 0.25f;

void OnUp(Finger f)
{
    var t = f.lastTouch;
    Vector2 delta = t.screenPosition - t.startScreenPosition;
    double duration = t.time - t.startTime;

    if (delta.magnitude < SwipeMinPixels * Screen.dpi / 160f && duration < TapMaxTime)
        OnTap(t.screenPosition);
    else if (delta.magnitude >= SwipeMinPixels)
        OnSwipe(Mathf.Abs(delta.x) > Mathf.Abs(delta.y)
            ? (delta.x > 0 ? Vector2.right : Vector2.left)
            : (delta.y > 0 ? Vector2.up : Vector2.down));
}

Scaling thresholds by Screen.dpi makes gestures feel the same on small and large screens (fall back to a fixed value if dpi returns 0).

Pinch to zoom compares the distance between two fingers this frame with last frame:

if (Touch.activeTouches.Count == 2)
{
    var a = Touch.activeTouches[0];
    var b = Touch.activeTouches[1];
    float current = Vector2.Distance(a.screenPosition, b.screenPosition);
    float previous = Vector2.Distance(a.screenPosition - a.delta, b.screenPosition - b.delta);
    float zoomDelta = (current - previous) * 0.01f;
    cam.orthographicSize = Mathf.Clamp(cam.orthographicSize - zoomDelta, 3f, 15f);
}

Ignoring Touches Over UI

A tap on a button shouldn’t also shoot or move the camera. Check whether a finger is over UI before handling it as gameplay input:

using UnityEngine.EventSystems;
using System.Collections.Generic;

static readonly List<RaycastResult> results = new();

static bool IsOverUI(Vector2 screenPos)
{
    var data = new PointerEventData(EventSystem.current) { position = screenPos };
    results.Clear();
    EventSystem.current.RaycastAll(data, results);
    return results.Count > 0;
}

Call it in OnDown and ignore fingers that start over UI. EventSystem.current.IsPointerOverGameObject() is less reliable with touch in the Input System, so the raycast approach is safer. Remember to untick Raycast Target on decorative images so they don’t block gameplay touches.

🕹️ Want a polished joystick pack?

Several Asset Store packages provide tested joysticks, gesture recognisers and rebindable mobile layouts. We’ve rounded up the tools worth buying.

See our Asset Store picks →

Testing Touch in the Editor

  • Device Simulator: switch the Game view to Simulator. Mouse clicks become touches, and you see the safe area and resolution of real phones.
  • Simulated Touchscreen: in Window > Analysis > Input Debugger, enable Options > Simulate Touch Input From Mouse or Pen, or call TouchSimulation.Enable().
  • Unity Remote 5 (app for Android/iOS): streams the Game view to your phone and sends real touches back. Multi-touch and pinch need a real device or Remote.
  • For the real thing, build to a device. See our iOS build guide or fix Android problems with the Gradle build fix guide.

Troubleshooting

Touch.activeTouches is always empty

EnhancedTouch isn’t enabled. Call EnhancedTouchSupport.Enable() before reading, typically in OnEnable.

“The type or namespace name ‘Touch’ is ambiguous”

Both UnityEngine.Touch and the EnhancedTouch Touch are in scope. Add the alias using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;.

UI buttons don’t respond to touch

The EventSystem still has the Standalone Input Module. Replace it with the Input System UI Input Module. Also check nothing transparent with Raycast Target is covering the buttons.

Joystick is a different size on every phone

Mixing screen pixels with canvas units. Multiply and divide by canvas.scaleFactor as in the script above.

Second finger is ignored

You’re reading Touchscreen.current.primaryTouch or the mouse pointer. Iterate Touch.activeTouches or use finger events.

FAQ

How do I detect touch in Unity’s new Input System?

Enable EnhancedTouchSupport and read Touch.activeTouches, or subscribe to Touch.onFingerDown/Move/Up. For simple cases, bind actions to <Touchscreen>/primaryTouch.

What’s the easiest way to add a virtual joystick?

Add the built-in On-Screen Stick component to a UI image and set its control path to <Gamepad>/leftStick. Your gamepad bindings then work on touch.

Can I test multi-touch in the Editor?

Not with a single mouse. Use Unity Remote 5 or build to a device.

Fixed or floating joystick?

Floating joysticks feel better for most action games because the thumb never misses the stick. Fixed joysticks suit games where muscle memory matters.

How big should touch buttons be?

At least about 9 mm (roughly 48 dp) for comfortable tapping. Use the safe area and keep important buttons within thumb reach.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *