Реализация Live Cursors (курсоры других пользователей) в мобильном приложении
Live cursors — один из тех элементов UX, которые выглядят просто, а реализация требует баланса между плавностью анимации, реальным трафиком и корректным масштабированием при десятках одновременных пользователей.
На вебе это решается через CSS-анимацию и transform: translate(). На мобиле — нативные Animated API или react-native-reanimated, UIView animation или Flutter AnimatedWidget. Сетевой уровень — Y.js Awareness или кастомный WebSocket-протокол.
Протокол: Awareness vs кастомный канал
Y.js Awareness Protocol — правильный выбор, если в приложении уже есть Y.js для синхронизации контента. Awareness хранит ephemeral-состояния: они не персистируются, не входят в историю изменений, автоматически удаляются при отключении пользователя.
// Обновляем позицию своего курсора
provider.awareness.setLocalStateField('cursor', {
x: normalizedX, // в координатах документа, не экрана
y: normalizedY,
timestamp: Date.now()
});
// Подписываемся на изменения чужих курсоров
provider.awareness.on('change', ({ updated }) => {
updated.forEach(clientId => {
if (clientId === provider.awareness.clientID) return;
const state = provider.awareness.getStates().get(clientId);
if (state?.cursor) {
updateRemoteCursor(clientId, state.cursor);
}
});
});
Если Y.js не используется — кастомный WebSocket-канал с throttle 30ms (≈33fps) достаточен для плавного ощущения. Более частые обновления не дают заметного улучшения UX, но увеличивают трафик.
Нормализация координат
Критический момент: координаты курсора нужно передавать в системе координат документа, не экрана. У разных пользователей разные zoom-уровни, размеры экрана, положение scroll. Если передавать screen coordinates — курсоры будут прыгать по экрану вместо плавного следования за реальной позицией.
Для scrollable документа: cursorX = (screenX + scrollX) / scale, cursorY = (screenY + scrollY) / scale. При получении обратно: displayX = documentX * scale - scrollX. При смене zoom у получателя — позиция курсора автоматически пересчитывается.
Интерполяция: плавное движение с задержкой сети
Raw-позиции от сервера — ступенчатые, особенно при задержке 100–200ms. Нужна интерполяция.
В React Native с react-native-reanimated:
const remoteCursorX = useSharedValue(0);
const remoteCursorY = useSharedValue(0);
// При получении новой позиции от сервера
const updateCursor = (x, y) => {
remoteCursorX.value = withSpring(x, { damping: 20, stiffness: 300 });
remoteCursorY.value = withSpring(y, { damping: 20, stiffness: 300 });
};
const animStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: remoteCursorX.value },
{ translateY: remoteCursorY.value },
]
}));
withSpring добавляет пружинную интерполяцию — курсор «догоняет» реальную позицию плавно. Альтернатива: withTiming с duration: 80 — проще, менее «живой».
На Flutter: AnimationController + Tween<Offset> с CurvedAnimation(curve: Curves.easeOut).
На нативном iOS: UIViewPropertyAnimator с .interruptible option — позволяет прерывать и перезапускать анимацию при новых позициях без артефактов.
Масштабирование: 50+ пользователей
При большом количестве пользователей несколько проблем:
Трафик. N пользователей × 33fps × ~50 байт = при 50 пользователях ~82 KB/s только на cursor updates. Решение: server-side throttling (сервер не пересылает updates чаще чем раз в 50ms на клиента) + отключение курсоров для пользователей за пределами viewport.
Рендеринг. 50 анимированных вьюшек одновременно на мобиле — нагрузка. Используем Canvas-based рендеринг вместо отдельных View для каждого курсора. Рисуем все курсоры в одном CustomPainter / SKCanvas / Canvas за один проход.
Идентификация. При 50 пользователях имя под курсором нечитаемо. Показываем имя только при hover/tap на курсор, остальное время — только цветовая точка с аватаром.
Отображение имени и аватара
Имя пользователя рядом с курсором — классический UX. Реализация: floating label, которая следует за курсором с небольшим offset. Проблема: при движении к краю экрана label выходит за bounds. Нужен clamp — если курсор ближе чем X px к правому краю, label отображается слева от курсора.
Аватар вместо стандартного указателя — часто лучше, чем цветная стрелка. Круглое изображение 24px диаметром, кэшированное в памяти.
Сроки
Live cursors как отдельный компонент (без полной collaborative системы) — 1–2 недели на платформу. В контексте полноценного collaborative приложения — одна из первых фич, которую реализуем после базовой синхронизации документа.







