Pointer Events
Pointer events, interaction state with GraphicState and ColorSet, holding and dragging, and scrolling.
Pointer events
Reactive has no input system of its own, and input comes from Unity's event system. The library only adds a way to attach handlers to a graphic, along with the habit of turning what they report into state.
WithPointerEvents attaches a handler to any graphic component, meaning anything with an image or text the event system
can hit. Try this demo, where a box logs every event it gets and shows the handler's flags below it:

var graphic = Remember(GraphicState.None);
new Background {
sColor = graphic.MapColorSet(Colors),
Do = x => x.WithPointerEvents(
onEnter: _ => Log("enter"),
onLeave: _ => Log("leave"),
onDown: _ => Log("down"),
onUp: _ => Log("up"),
// Runs after every other callback
onUpdate: h => {
graphic.IsHovered = h.IsHovered;
graphic.IsActive = h.IsPressed;
}
)
}Hover the box, click it, drag across it and scroll over it, and watch the log and the flags change. Each callback
receives the handler, which keeps IsHovered, IsPressed, IsDragging and the latest event data from Unity, and each
one runs at a different point of an interaction:
onEnterwhen the pointer moves onto the graphic or a child from outside them.onLeavewhen the pointer leaves the graphic and its children.onDownwhen a press starts over it, unless a child with its own handler takes it.onUpwhen that press ends, wherever the pointer is, so everyonDowngets anonUp.onDragBeginonce a press moves past the drag threshold.onDragon every move during a drag, even over other graphics.onDragEndwhen a drag is released, right afteronUp.onScrollon a scroll, which goes to the parent when this callback is missing.onUpdateafter each of the others and on moves, every hovered frame in the game.
If the object is disabled in the middle of an interaction, the handler sends the matching leave, up and drag end itself, so no state gets stuck.
Interaction state
The first demo already used the two types every interactive component in the SDK is built from. The first,
GraphicState, holds the flags for hovered, active and not interactable, and its properties flip one flag at a time on
a state that holds it. The second, ColorSet, turns those flags into a color, with one for each combination and a
fallback to the closest one you set, then to the plain Color. MapColorSet connects the two by turning a graphic
state into a color state you can bind.
Below you can see a button built from both, with a toggle that turns off its interactability:

static readonly ColorSet colors = new() {
Color = new Color(1f, 1f, 1f, 0.1f),
HoveredColor = new Color(0f, 0.75f, 1f, 0.5f),
ActiveColor = new Color(0f, 0.75f, 1f, 0.9f),
NotInteractableColor = new Color(1f, 1f, 1f, 0.03f)
};
var graphic = Remember(GraphicState.None);
new Background {
sColor = graphic.MapColorSet(colors),
Do = x => x.WithPointerEvents(
onEnter: _ => graphic.IsHovered = true,
onLeave: _ => graphic.IsHovered = false,
onDown: _ => graphic.IsActive = true,
onUp: _ => graphic.IsActive = false
)
}Hover and press the button, then switch it off with the toggle, and its color and press scale follow without any handler
knowing about the toggle. That works because the pointer events only flip flags and everything else reads them: the
background and the label each pass the flags through their own color set, and the press scale is an animated state
reading the same flags. So switching the button off is just graphic.IsInteractable = false, and every color set
reacts to it with no special case anywhere. The SDK's components work the same way, with a Colors record that holds
one color set for each part of a component.
Input that lasts
Events tell you when a press starts and ends, but a hold is about what happens in between, and that takes time. So the event is stored as state, and something that runs over time, an animation or OnUpdate, does the
rest.
For a hold, onDown sets a pressed state and onUp and onLeave clear it. An animated progress then fills while it's
set and drains when it's cleared, and finishing a fill confirms once, as this hold-to-confirm button shows:

pressed.AddCallback(x => {
progress.Timing = x ? 1200.ms : (progress.Value * 1200 / 3f).ms;
progress.TargetValue = x ? 1f : 0f;
});
// Drop the press once full, so the bar drains even if still held
progress.OnFinish = x => {
if (x >= 1f) {
confirmed.Value++;
pressed.Value = false;
}
};
new Background {
Do = x => x.WithPointerEvents(
onDown: _ => pressed.Value = true,
onUp: _ => pressed.Value = false,
onLeave: _ => pressed.Value = false
)
}Let go early and the bar drains three times faster than it filled. The drain time is set from how full the bar is, so it keeps that speed from wherever you let go, while a fixed time would make an almost empty bar crawl back as slowly as a full one. If you hold to the end instead, the button confirms and drops the press, so the bar drains even while you keep holding, and deleting again takes a new press.
Dragging
Hover and move events reach an object only while the pointer is over it or one of its children, but drag events don't
depend on that. Once a press moves past the drag threshold, the input module sends drag events to the pressed object
whenever the pointer moves, until the button is released, even while the pointer is over other graphics. So following
the pointer only takes turning each event into a position in the pressed object's own space, which
InverseTransformPoint does with the point the pointer's raycast hit.
The pad below makes that position the end of a glide, so press anywhere on it and drag past its edges:

var from = Remember(Vector2.zero);
var to = Remember(Vector2.zero);
var progress = RememberAnimated(
0f,
200.ms,
AnimationCurve.EaseOutCubic
);
// The knob's offset from the center of the pad
var offset = RememberDerived(
from,
to,
progress,
predicate: (a, b, t) => Vector2.Lerp(a, b, t)
);
// Starts a glide from wherever the knob is now
void GlideTo(Vector2 point) {
from.Value = offset.Value;
to.Value = point;
progress.SetValueImmediate(0f);
progress.TargetValue = 1f;
}
new Background {
// Drag events keep coming after the pointer leaves the pad
Do = x => x.WithPointerEvents(
onDown: h => GlideTo(ToOffset(h)),
onDrag: h => to.Value = ToOffset(h),
onUp: _ => GlideTo(Vector2.zero)
)
}
// The pointer's offset from the pad's center, clamped to the pad
private static Vector2 ToOffset(PointerEventsHandler handler) {
var rect = ((RectTransform)handler.transform).rect;
var world = handler.EventData!.pointerCurrentRaycast.worldPosition;
Vector2 local = handler.transform.InverseTransformPoint(world);
local -= rect.center;
var maxX = (rect.width - KnobSize) / 2f;
var maxY = (rect.height - KnobSize) / 2f;
return new Vector2(
Mathf.Clamp(local.x, -maxX, maxX),
Mathf.Clamp(local.y, -maxY, maxY)
);
}The knob glides to where you press and sticks to the pointer once it gets there. Past the pad's edges it slides along them instead of freezing, since the drag events keep coming. That works because the knob's position is a blend from where it was to where the pointer is, and only the blend is animated. A drag moves the end of the blend without restarting it, so the glide always takes the same time, and letting go is the same glide toward the center. The end itself is clamped to the pad's rect as it is at that moment, so the pad can take any size, and here it stretches with the window.
Scrolling
Scrolling arrives like the other events, through onScroll, with the wheel's movement in the event data's
scrollDelta. Its y is positive when the wheel turns up and negative when it turns down, while its size depends on the
mouse and the system settings, so the same notch can arrive as a whole step or a fraction of one. That makes the sign
the only part worth trusting, and this number steps by one for each scroll in either direction:

Do = x => x.WithPointerEvents(onScroll: h => {
var delta = h.EventData?.scrollDelta.y ?? 0f;
if (delta != 0f) {
value.Value = Mathf.Clamp(
value.Value + (delta > 0f ? 1 : -1),
Min,
Max
);
}
})Each notch moves the number by one, however far the mouse says it scrolled, and the number stops at both ends. Content
that only needs to scroll doesn't need any of this, since a ScrollArea, or a Table for long lists, handles it for
you. See Repeaters and Tables.
In VR there's no mouse wheel, so on its own onScroll only works in FPFC. To scroll with the joystick as well, add
a VRScrollAdapter component to the graphic, the same one scroll areas and tables use.