---@class GestureRecognizer ---@field _touches table -- Current touch states ---@field _gestureStates table -- Active gesture states ---@field _config table -- Gesture configuration (thresholds, etc.) ---@field _InputEvent table ---@field _utils table local GestureRecognizer = {} GestureRecognizer.__index = GestureRecognizer -- Gesture types enum local GestureType = { TAP = "tap", DOUBLE_TAP = "double_tap", LONG_PRESS = "long_press", SWIPE = "swipe", PAN = "pan", PINCH = "pinch", ROTATE = "rotate", } -- Gesture states local GestureState = { POSSIBLE = "possible", BEGAN = "began", CHANGED = "changed", ENDED = "ended", CANCELLED = "cancelled", FAILED = "failed", } -- Default configuration local defaultConfig = { -- Tap gesture tapMaxDuration = 0.3, -- seconds tapMaxMovement = 10, -- pixels -- Double-tap gesture doubleTapInterval = 0.3, -- seconds between taps -- Long-press gesture longPressMinDuration = 0.5, -- seconds longPressMaxMovement = 10, -- pixels -- Swipe gesture swipeMinDistance = 50, -- pixels swipeMaxDuration = 0.2, -- seconds swipeMinVelocity = 200, -- pixels per second -- Pan gesture panMinMovement = 5, -- pixels to start pan -- Pinch gesture pinchMinScaleChange = 0.1, -- 10% scale change -- Rotate gesture rotateMinAngleChange = 5, -- degrees } --- Create a new GestureRecognizer instance ---@param config table? Optional configuration options ---@param deps table Dependencies {InputEvent, utils} ---@return GestureRecognizer function GestureRecognizer.new(config, deps) config = config or {} local self = setmetatable({}, GestureRecognizer) self._InputEvent = deps.InputEvent self._utils = deps.utils -- Merge configuration with defaults self._config = {} for key, value in pairs(defaultConfig) do self._config[key] = config[key] or value end self._touches = {} self._gestureStates = { tap = nil, doubleTap = { lastTapTime = 0, tapCount = 0 }, longPress = {}, swipe = {}, pan = {}, pinch = {}, rotate = {}, } return self end --- Update gesture recognizer with touch event ---@param event InputEvent Touch event function GestureRecognizer:processTouchEvent(event) if not event.touchId then return end local touchId = event.touchId -- Update touch state if event.type == "touchpress" then self._touches[touchId] = { startX = event.x, startY = event.y, x = event.x, y = event.y, startTime = event.timestamp, lastTime = event.timestamp, phase = "began", } -- Initialize gesture detection self:_detectTapBegan(touchId, event) self:_detectLongPressBegan(touchId, event) elseif event.type == "touchmove" then local touch = self._touches[touchId] if touch then touch.x = event.x touch.y = event.y touch.lastTime = event.timestamp touch.phase = "moved" -- Update gesture detection self:_detectPan(touchId, event) self:_detectSwipe(touchId, event) -- Multi-touch gestures if self:_getTouchCount() >= 2 then self:_detectPinch(event) self:_detectRotate(event) end end elseif event.type == "touchrelease" then local touch = self._touches[touchId] if touch then touch.phase = "ended" -- Finalize gesture detection self:_detectTapEnded(touchId, event) self:_detectSwipeEnded(touchId, event) self:_detectPanEnded(touchId, event) -- Cleanup touch self._touches[touchId] = nil end elseif event.type == "touchcancel" then -- Cancel all active gestures for this touch self._touches[touchId] = nil self:_cancelAllGestures() end end --- Get number of active touches ---@return number function GestureRecognizer:_getTouchCount() local count = 0 for _ in pairs(self._touches) do count = count + 1 end return count end --- Detect tap gesture began ---@param touchId string ---@param event InputEvent function GestureRecognizer:_detectTapBegan(touchId, event) -- Tap detection happens on touch end -- Just record the touch for now end --- Detect tap gesture ended ---@param touchId string ---@param event InputEvent function GestureRecognizer:_detectTapEnded(touchId, event) local touch = self._touches[touchId] if not touch then return end local duration = event.timestamp - touch.startTime local dx = event.x - touch.startX local dy = event.y - touch.startY local distance = math.sqrt(dx * dx + dy * dy) -- Check if it's a valid tap if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then local currentTime = event.timestamp local doubleTapState = self._gestureStates.doubleTap -- Check for double-tap if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then doubleTapState.tapCount = doubleTapState.tapCount + 1 if doubleTapState.tapCount >= 2 then -- Fire double-tap gesture return { type = GestureType.DOUBLE_TAP, state = GestureState.ENDED, x = event.x, y = event.y, timestamp = event.timestamp, } end else doubleTapState.tapCount = 1 end doubleTapState.lastTapTime = currentTime -- Fire tap gesture return { type = GestureType.TAP, state = GestureState.ENDED, x = event.x, y = event.y, timestamp = event.timestamp, } end end --- Detect long-press gesture began ---@param touchId string ---@param event InputEvent function GestureRecognizer:_detectLongPressBegan(touchId, event) -- Long-press detection happens continuously during touch self._gestureStates.longPress[touchId] = { startX = event.x, startY = event.y, startTime = event.timestamp, triggered = false, } end --- Update long-press detection ---@param touchId string ---@param event InputEvent ---@return table? Gesture event function GestureRecognizer:_updateLongPress(touchId, event) local lpState = self._gestureStates.longPress[touchId] if not lpState or lpState.triggered then return nil end local duration = event.timestamp - lpState.startTime local dx = event.x - lpState.startX local dy = event.y - lpState.startY local distance = math.sqrt(dx * dx + dy * dy) -- Check if long-press duration reached and movement within threshold if duration >= self._config.longPressMinDuration and distance < self._config.longPressMaxMovement then lpState.triggered = true return { type = GestureType.LONG_PRESS, state = GestureState.BEGAN, x = event.x, y = event.y, timestamp = event.timestamp, duration = duration, } end return nil end --- Detect pan gesture ---@param touchId string ---@param event InputEvent ---@return table? Gesture event function GestureRecognizer:_detectPan(touchId, event) local touch = self._touches[touchId] if not touch then return nil end local dx = event.x - touch.startX local dy = event.y - touch.startY local distance = math.sqrt(dx * dx + dy * dy) local panState = self._gestureStates.pan[touchId] if not panState then -- Check if pan should begin if distance >= self._config.panMinMovement then self._gestureStates.pan[touchId] = { active = true, lastX = touch.startX, lastY = touch.startY, } panState = self._gestureStates.pan[touchId] return { type = GestureType.PAN, state = GestureState.BEGAN, x = event.x, y = event.y, dx = dx, dy = dy, timestamp = event.timestamp, } end else -- Pan is active, fire changed event local panDx = event.x - panState.lastX local panDy = event.y - panState.lastY panState.lastX = event.x panState.lastY = event.y return { type = GestureType.PAN, state = GestureState.CHANGED, x = event.x, y = event.y, dx = panDx, dy = panDy, totalDx = dx, totalDy = dy, timestamp = event.timestamp, } end return nil end --- Detect pan ended ---@param touchId string ---@param event InputEvent ---@return table? Gesture event function GestureRecognizer:_detectPanEnded(touchId, event) local panState = self._gestureStates.pan[touchId] if panState and panState.active then self._gestureStates.pan[touchId] = nil local touch = self._touches[touchId] local dx = event.x - touch.startX local dy = event.y - touch.startY return { type = GestureType.PAN, state = GestureState.ENDED, x = event.x, y = event.y, dx = dx, dy = dy, timestamp = event.timestamp, } end return nil end --- Detect swipe gesture ---@param touchId string ---@param event InputEvent function GestureRecognizer:_detectSwipe(touchId, event) -- Swipe detection happens on touch end end --- Detect swipe ended ---@param touchId string ---@param event InputEvent ---@return table? Gesture event function GestureRecognizer:_detectSwipeEnded(touchId, event) local touch = self._touches[touchId] if not touch then return nil end local duration = event.timestamp - touch.startTime local dx = event.x - touch.startX local dy = event.y - touch.startY local distance = math.sqrt(dx * dx + dy * dy) -- Check if it's a valid swipe if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then local velocity = distance / duration if velocity >= self._config.swipeMinVelocity then -- Determine swipe direction local angle = math.atan2(dy, dx) local direction = "right" if angle >= -math.pi / 4 and angle < math.pi / 4 then direction = "right" elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then direction = "down" elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then direction = "up" else direction = "left" end return { type = GestureType.SWIPE, state = GestureState.ENDED, x = event.x, y = event.y, dx = dx, dy = dy, direction = direction, velocity = velocity, timestamp = event.timestamp, } end end return nil end --- Detect pinch gesture ---@param event InputEvent ---@return table? Gesture event function GestureRecognizer:_detectPinch(event) -- Get two touches for pinch local touches = {} for touchId, touch in pairs(self._touches) do table.insert(touches, { id = touchId, touch = touch }) if #touches >= 2 then break end end if #touches < 2 then return nil end local t1 = touches[1].touch local t2 = touches[2].touch -- Calculate current distance local currentDx = t2.x - t1.x local currentDy = t2.y - t1.y local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy) -- Calculate initial distance local initialDx = t2.startX - t1.startX local initialDy = t2.startY - t1.startY local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy) if initialDistance == 0 then return nil end -- Calculate scale local scale = currentDistance / initialDistance local pinchState = self._gestureStates.pinch if not pinchState.active then -- Check if pinch should begin if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then pinchState.active = true pinchState.initialScale = scale pinchState.lastScale = scale -- Calculate center point local centerX = (t1.x + t2.x) / 2 local centerY = (t1.y + t2.y) / 2 return { type = GestureType.PINCH, state = GestureState.BEGAN, scale = scale, centerX = centerX, centerY = centerY, timestamp = event.timestamp, } end else -- Pinch is active, fire changed event local centerX = (t1.x + t2.x) / 2 local centerY = (t1.y + t2.y) / 2 local scaleChange = scale - pinchState.lastScale pinchState.lastScale = scale return { type = GestureType.PINCH, state = GestureState.CHANGED, scale = scale, scaleChange = scaleChange, centerX = centerX, centerY = centerY, timestamp = event.timestamp, } end return nil end --- Detect rotate gesture ---@param event InputEvent ---@return table? Gesture event function GestureRecognizer:_detectRotate(event) -- Get two touches for rotation local touches = {} for touchId, touch in pairs(self._touches) do table.insert(touches, { id = touchId, touch = touch }) if #touches >= 2 then break end end if #touches < 2 then return nil end local t1 = touches[1].touch local t2 = touches[2].touch -- Calculate current angle local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x) -- Calculate initial angle local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX) -- Calculate rotation (in degrees) local rotation = (currentAngle - initialAngle) * 180 / math.pi local rotateState = self._gestureStates.rotate if not rotateState.active then -- Check if rotation should begin if math.abs(rotation) >= self._config.rotateMinAngleChange then rotateState.active = true rotateState.initialRotation = rotation rotateState.lastRotation = rotation -- Calculate center point local centerX = (t1.x + t2.x) / 2 local centerY = (t1.y + t2.y) / 2 return { type = GestureType.ROTATE, state = GestureState.BEGAN, rotation = rotation, centerX = centerX, centerY = centerY, timestamp = event.timestamp, } end else -- Rotation is active, fire changed event local centerX = (t1.x + t2.x) / 2 local centerY = (t1.y + t2.y) / 2 local rotationChange = rotation - rotateState.lastRotation rotateState.lastRotation = rotation return { type = GestureType.ROTATE, state = GestureState.CHANGED, rotation = rotation, rotationChange = rotationChange, centerX = centerX, centerY = centerY, timestamp = event.timestamp, } end return nil end --- Cancel all active gestures function GestureRecognizer:_cancelAllGestures() for gestureType, state in pairs(self._gestureStates) do if type(state) == "table" and state.active then state.active = false end end end --- Reset gesture recognizer state function GestureRecognizer:reset() self._touches = {} self._gestureStates = { tap = nil, doubleTap = { lastTapTime = 0, tapCount = 0 }, longPress = {}, swipe = {}, pan = {}, pinch = { active = false }, rotate = { active = false }, } end -- Export gesture types and states GestureRecognizer.GestureType = GestureType GestureRecognizer.GestureState = GestureState return GestureRecognizer