Skip to main content

Events & Input: Gesture Lab

A 2 × 2 grid of input zones, each demonstrating a distinct event pattern. The cyan TapZone counts GestureType.Tap events; the orange DragZone follows your finger via GestureType.Drag and snaps back on GestureType.Drop; the purple HoldZone reports GestureType.LongTap duration and fires throttled HapticsModule.triggerVibration while held; the green RawZone counts raw EventType.Touch Began events. A status line at the top mirrors the most recent event so you can read which emitter fired and on which zone.

Gesture Lab demo running in Effect House preview

What you'll build

  • Four 250 × 250 colored Screen Image "zones" — each parents a 2D Text child label that displays the zone's running state (count, ms, etc.).
  • A StatusText at the top that mirrors the most recent input event (Touch.Began, GestureType.Tap, LongTap, Drag start, Drop).
  • A GameController empty SceneObject hosting GestureLab — one script that subscribes to two separate emitters:
    • EventManager.getGlobalEmitter() for raw Touch events.
    • EventManager.getGestureEmitter() for semantic Tap, LongTap, Drag, Drop gestures.
  • A scale-bounce on the TapZone, an offset-tracking drag on the DragZone (with snap-back animation on Drop), throttled haptic vibration during a long-press, and a hit-tested raw Touch counter on the RawZone.

Touch vs Gesture — when to use which

The two emitters serve different needs and are easy to confuse:

EmitterFires forUse it when
getGlobalEmitter() + EventType.TouchEvery raw finger phase: Began, Moved, Ended, Canceled. One event per phase per finger.You need the precise per-frame finger data — drawing apps, multi-finger games, custom drag implementations, hit-testing buttons (with TouchUtils.isScreenPointOnImage).
getGestureEmitter() + GestureType.{Tap,LongTap,Drag,Drop}Recognised gestures: Tap after a short press-release, LongTap while a press is sustained, Drag for each frame of a drag-in-progress, Drop on release after a drag.You want semantic input — "did the user tap this thing?", "is this a long-press?". The engine handles the timing/movement thresholds for you.

The demo uses both side-by-side so you can see the difference. The RawZone counts Touch.Began events; the TapZone counts GestureType.Tap. A single quick press on the TapZone fires both — one Touch.Began (counted on RawZone if it's the rawZone, otherwise ignored by the script) plus one GestureType.Tap (counted on TapZone). A held press on the HoldZone fires many Touch.Moved events but only one GestureType.LongTap chain.

Open the demo

↓ gesture-lab.zip

Unzip and open in Effect House (5.9.0+). The opening scene contains:

  • Camera — default 3D perspective camera, untouched.
  • 2D Camera — auto-created by the first 2D Text.
  • TitleText — "Gesture Lab" at the top.
  • StatusText — yellow status line that mirrors the latest event.
  • TapZone (cyan) at (-140, 150) with TapLabel child ("Tap × N").
  • DragZone (orange) at (140, 150) with DragLabel child ("Drag me" / "Dragging").
  • HoldZone (purple) at (-140, -150) with HoldLabel child ("Long-tap N ms").
  • RawZone (green) at (140, -150) with RawLabel child ("Raw Touch × N").
  • HintText at the bottom with a short usage hint.
  • GameController — empty SceneObject hosting GestureLab.

Read the script

GestureLab.ts

@component()
export class GestureLab extends APJS.BasicScriptComponent {
// Live status + zone references.
@serializeProperty statusText!: APJS.SceneObject;
@serializeProperty tapZone!: APJS.SceneObject;
@serializeProperty tapLabel!: APJS.SceneObject;
@serializeProperty dragZone!: APJS.SceneObject;
@serializeProperty dragLabel!: APJS.SceneObject;
@serializeProperty holdZone!: APJS.SceneObject;
@serializeProperty holdLabel!: APJS.SceneObject;
@serializeProperty rawZone!: APJS.SceneObject;
@serializeProperty rawLabel!: APJS.SceneObject;

// Tunables.
@serializeProperty hapticDurationMs: number = 30;
@serializeProperty hapticIntervalMs: number = 250; // throttle haptics during a hold

// Component caches (resolved in lazy init).
private statusComp!: APJS.Text;
private tapImg!: APJS.Image;
private dragImg!: APJS.Image;
private holdImg!: APJS.Image;
private rawImg!: APJS.Image;
private tapLabelText!: APJS.Text;
private dragLabelText!: APJS.Text;
private holdLabelText!: APJS.Text;
private rawLabelText!: APJS.Text;
private tapST!: APJS.ScreenTransform;
private dragST!: APJS.ScreenTransform;

// Counters + state.
private tapCount: number = 0;
private rawCount: number = 0;
private tapBouncePulse: number = 0; // 0..1, decays with dt
private dragOrigin!: APJS.Vector2f; // resting anchored position of the DragZone
private dragGrabbed: boolean = false;
private dragStartNorm!: APJS.Vector2f;
private dragStartAnchored!: APJS.Vector2f;
private snapBackUntil: number = 0; // elapsed time at which snap-back finishes
private snapBackFrom!: APJS.Vector2f;
private elapsed: number = 0;
private lastHapticAt: number = -999;
private inited: boolean = false;

// Touch / gesture callback refs (so we can remove on destroy).
private touchCallback!: (e: APJS.IEvent) => void;
private tapGestureCallback!: (e: APJS.IEvent) => void;
private longTapCallback!: (e: APJS.IEvent) => void;
private dragCallback!: (e: APJS.IEvent) => void;
private dropCallback!: (e: APJS.IEvent) => void;

onUpdate(dt: number): void {
if (!this.inited) {
if (
!this.statusText || !this.tapZone || !this.tapLabel ||
!this.dragZone || !this.dragLabel || !this.holdZone ||
!this.holdLabel || !this.rawZone || !this.rawLabel
) return;

this.statusComp = this.statusText.getComponent("Text") as APJS.Text;
this.tapImg = this.tapZone.getComponent("Image") as APJS.Image;
this.dragImg = this.dragZone.getComponent("Image") as APJS.Image;
this.holdImg = this.holdZone.getComponent("Image") as APJS.Image;
this.rawImg = this.rawZone.getComponent("Image") as APJS.Image;
this.tapLabelText = this.tapLabel.getComponent("Text") as APJS.Text;
this.dragLabelText = this.dragLabel.getComponent("Text") as APJS.Text;
this.holdLabelText = this.holdLabel.getComponent("Text") as APJS.Text;
this.rawLabelText = this.rawLabel.getComponent("Text") as APJS.Text;
this.tapST = this.tapZone.getComponent("ScreenTransform") as APJS.ScreenTransform;
this.dragST = this.dragZone.getComponent("ScreenTransform") as APJS.ScreenTransform;

// Snapshot the drag zone's resting position so Drop can snap it back.
const o = this.dragST.anchoredPosition;
this.dragOrigin = new APJS.Vector2f(o.x, o.y);

this.subscribe();
this.inited = true;
console.log("[GestureLab] ready");
}

this.elapsed += dt;

// Tap bounce decay: scale 1 + 0.2*pulse, lerp pulse to 0.
if (this.tapBouncePulse > 0) {
this.tapBouncePulse = Math.max(0, this.tapBouncePulse - dt * 4);
const s = 1 + 0.2 * this.tapBouncePulse;
this.tapST.scale = new APJS.Vector2f(s, s);
}

// Snap-back animation after Drop: lerp the drag zone back to its origin.
if (this.snapBackUntil > this.elapsed) {
const total = 0.25;
const t = 1 - Math.max(0, (this.snapBackUntil - this.elapsed) / total);
const k = Math.min(1, t);
this.dragST.anchoredPosition = new APJS.Vector2f(
this.snapBackFrom.x + (this.dragOrigin.x - this.snapBackFrom.x) * k,
this.snapBackFrom.y + (this.dragOrigin.y - this.snapBackFrom.y) * k,
);
} else if (this.snapBackUntil > 0) {
// Snap finished — freeze at origin and clear the marker.
this.dragST.anchoredPosition = this.dragOrigin.clone();
this.snapBackUntil = 0;
}
}

onDestroy(): void {
if (!this.inited) return;
APJS.EventManager.getGlobalEmitter().off(APJS.EventType.Touch, this.touchCallback, this);
const ge = APJS.EventManager.getGestureEmitter();
ge.off(APJS.GestureType.Tap, this.tapGestureCallback, this);
ge.off(APJS.GestureType.LongTap, this.longTapCallback, this);
ge.off(APJS.GestureType.Drag, this.dragCallback, this);
ge.off(APJS.GestureType.Drop, this.dropCallback, this);
}

private subscribe(): void {
// Global emitter — raw Touch events. We count finger-down events on RawZone only.
this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase !== APJS.TouchPhase.Began) return;
if (!APJS.TouchUtils.isScreenPointOnImage(t.position, this.rawImg)) return;
this.rawCount++;
this.rawLabelText.text = "Raw Touch\n× " + this.rawCount;
this.statusComp.text = "Touch.Began — raw count = " + this.rawCount;
};
APJS.EventManager.getGlobalEmitter().on(APJS.EventType.Touch, this.touchCallback, this);

// Gesture emitter — semantic gestures (Tap / LongTap / Drag / Drop).
const gesture = APJS.EventManager.getGestureEmitter();

this.tapGestureCallback = (event: APJS.IEvent) => {
const g = event.args[0] as APJS.GestureInfo;
if (!APJS.TouchUtils.isScreenPointOnImage(g.endPoint, this.tapImg)) return;
this.tapCount++;
this.tapBouncePulse = 1; // triggers the scale-bounce in onUpdate
this.tapLabelText.text = "Tap\n× " + this.tapCount;
this.statusComp.text = "GestureType.Tap fired";
};
gesture.on(APJS.GestureType.Tap, this.tapGestureCallback, this);

this.longTapCallback = (event: APJS.IEvent) => {
const g = event.args[0] as APJS.GestureInfo;
if (!APJS.TouchUtils.isScreenPointOnImage(g.endPoint, this.holdImg)) return;
const ms = Math.round(g.duration);
this.holdLabelText.text = "Long-tap\n" + ms + " ms";
this.statusComp.text = "LongTap firing — " + ms + " ms";
// Throttle haptics so a long hold doesn't continuously buzz.
if (this.elapsed * 1000 - this.lastHapticAt > this.hapticIntervalMs) {
APJS.HapticsModule.triggerVibration(this.hapticDurationMs, 0.7, 0.5);
this.lastHapticAt = this.elapsed * 1000;
}
};
gesture.on(APJS.GestureType.LongTap, this.longTapCallback, this);

this.dragCallback = (event: APJS.IEvent) => {
const g = event.args[0] as APJS.GestureInfo;
if (g.firstTrigger) {
// The drag has just started — only follow the finger if it began on DragZone.
if (!APJS.TouchUtils.isScreenPointOnImage(g.startPoint, this.dragImg)) {
this.dragGrabbed = false;
return;
}
this.dragGrabbed = true;
this.dragStartNorm = new APJS.Vector2f(g.startPoint.x, g.startPoint.y);
const a = this.dragST.anchoredPosition;
this.dragStartAnchored = new APJS.Vector2f(a.x, a.y);
this.statusComp.text = "GestureType.Drag started";
}
if (!this.dragGrabbed) return;

// Convert the normalized finger delta to anchored-position pixels.
// ScreenTransform y-axis is up, gesture y-axis is down — flip.
const dx = (g.endPoint.x - this.dragStartNorm.x) * 720;
const dy = -(g.endPoint.y - this.dragStartNorm.y) * 1280;
this.dragST.anchoredPosition = new APJS.Vector2f(
this.dragStartAnchored.x + dx,
this.dragStartAnchored.y + dy,
);
this.dragLabelText.text = "Dragging";
};
gesture.on(APJS.GestureType.Drag, this.dragCallback, this);

this.dropCallback = (event: APJS.IEvent) => {
if (!this.dragGrabbed) return;
this.dragGrabbed = false;
// Trigger the snap-back animation in onUpdate.
const a = this.dragST.anchoredPosition;
this.snapBackFrom = new APJS.Vector2f(a.x, a.y);
this.snapBackUntil = this.elapsed + 0.25;
this.dragLabelText.text = "Drag me";
this.statusComp.text = "GestureType.Drop — snapping back";
};
gesture.on(APJS.GestureType.Drop, this.dropCallback, this);
}
}

The Events-namespace calls of interest:

  • APJS.EventManager.getGlobalEmitter() is the project-wide bus for raw Touch / RecordStart / RecordEnd events. Subscribe with .on(APJS.EventType.Touch, callback, this). The callback receives an IEvent whose args[0] is a TouchData.
  • APJS.EventManager.getGestureEmitter() is a separate bus for recognised gestures. Subscribe with .on(APJS.GestureType.X, callback, this). The callback's args[0] is a GestureInfo.
  • TouchData.phase is one of Began, Moved, Ended, Canceled. Filter on Began for "single press" mechanics; use Moved for drawing or active-drag implementations; use Ended / Canceled for cleanup or release-time logic.
  • TouchData.position is a normalized Vector2f with (0,0) = top-left and (1,1) = bottom-right. Pass it directly to TouchUtils.isScreenPointOnImage — no axis-flipping.
  • GestureInfo.endPoint is the normalized 0-1 point where the gesture ended (or is currently). Always populated, on every gesture type.
  • GestureInfo.startPoint is the gesture's origin — only valid for Drag and Drop. Used here to test "did the drag start on DragZone?" exactly once per drag sequence.
  • GestureInfo.firstTrigger is true exactly once per drag sequence — on the very first Drag event. Use it to snapshot the grab-offset and skip irrelevant drags that didn't start on your zone.
  • GestureInfo.duration (LongTap only) is the press duration in milliseconds, updating each LongTap event.
  • APJS.HapticsModule.triggerVibration(durationMs, strength, frequency) fires device vibration. durationMs is 10–200 (default 30), strength and frequency are 0–1 (defaults 0.7 and 0.5). Throttle yourself — the demo limits haptics to once every 250 ms while a long-press is active.
  • APJS.TouchUtils.isScreenPointOnImage(point, image) is the hit-test helper. Pass the raw touchInfo.position or gestureInfo.endPoint and the Image component — no coordinate conversion needed, the helper handles every layout case.

Customize

On GameControllerGestureLab:

  • hapticDurationMs (default 30) — vibration pulse length per fire. Must be 10–200; the engine clamps outside that range.
  • hapticIntervalMs (default 250) — minimum gap between consecutive haptics during a long-press. Lower for buzzier feel, higher for a single soft confirmation.

Per-zone tweaks (no script changes needed):

  • TapZone / DragZone / HoldZone / RawZone color — drop in any hue via the inspector's Image.color.
  • Zone label fontSize / bold / color — tune in the inspector; the script only writes Text.text.

Suggestions for further play:

  • Add a 5th gesture: subscribe to a custom multi-finger pinch pattern using two Touch.Moved events and dot-product the velocities. The raw Touch emitter is the right tool for that.
  • Replace the snap-back lerp with a Tween on ScreenTransform.anchoredPosition (see the TweenAnimation reference for the Tween primitives, currently grouped under Post-Process).
  • Combine with the Audio tutorial: play a pop SFX on every Tap, a low buzz on every LongTap — gives the haptic feedback an audible companion.

What you learned

This tutorial used:

  • EventManager.getGlobalEmitter() + EventType.Touch for raw finger events (Began, Moved, Ended, Canceled).
  • EventManager.getGestureEmitter() + GestureType.{Tap, LongTap, Drag, Drop} for semantic gestures.
  • TouchDataposition (normalized 0-1, top-left origin), phase.
  • GestureInfostartPoint, endPoint, firstTrigger, duration.
  • TouchUtils.isScreenPointOnImage for zone-based hit-testing on either a TouchData position or a GestureInfo endPoint.
  • HapticsModule.triggerVibration for tactile feedback, throttled by elapsed time.
  • Lazy unsubscribe in onDestroy — the symmetrical .off(...) pattern matters because the emitters outlive the script's lifecycle.

Read the full EventManager reference, the EventType, IEvent, and IEventEmitter references; the TouchData, TouchPhase, and TouchUtils for raw input; the GestureInfo and GestureType for semantic gestures; the HapticsModule for vibration; and the Events & Input namespace overview.

For UI-side hit-testing patterns and ScreenTransform layout, see the 2D / UI tutorial.

Copyright © 2026 TikTok. All rights reserved.
About TikTokHelp CenterCareersContactLegalTerms of ServicePrivacy PolicyCookies