595 lines
15 KiB
Lua
595 lines
15 KiB
Lua
---@class GestureRecognizer
|
|
---@field _touches table<string, 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
|