Эта статья – продолжение моей серии статей о разработке геолокационной соц.сети, над которой я работал уже почти 10 лет назад, но многое из той работы актуально и сейчас, и мне хотелось бы поделиться с вами ещё одним блоком. В геолокационной соцсети есть очевидный экран – «люди вокруг тебя». И есть очевидный способ его сделать: обычный список, отсортированный по расстоянию. Аватар, имя, «1.2 км», следующий пользователь, ещё один, и так далее вниз.
Технически это самый простой вариант. Но по задумке авторов проекта это должен был быть не список, а нечто более интересное: пользователь находится в центре экрана, а вокруг него кольцами расходятся люди – как планеты на орбитах. Чем дальше кольцо – тем дальше человек. Что-то ближе к радару, чем к таблице. Экран можно крутить, тапать по аватаркам, а расстояние считывается не как число, а как положение человека относительно тебя.
Список в таком сценарии слишком сильно упрощает идею. Он показывает порядок, но не показывает ощущение «вокруг». А здесь как раз было важно, чтобы пользователь видел не просто набор профилей, а пространство рядом с собой. Для таких целей стандартных компонентов у нас нет, поэтому надо было что-то придумать новое. Конечно, можно повесить круглые UIView, но в тот момент я активно изучал разные возможности iOS, так что решил попробовать использовать SpriteKit.
Подход
В основе экрана – концентрические кольца вокруг пользователя. Каждое кольцо отвечает за свой уровень дистанции: ближе к центру – люди рядом, дальше от центра – люди на большем расстоянии.
Каждый человек – это узел UserNode: SKShapeNode. Он знает свой угол на дуге, радиус кольца, пользователя и аватар:
class UserNode: SKShapeNode { var angle: CGFloat = 0 // угол на дуге var radius: CGFloat = 0 // радиус кольца var image: UIImage? weak var imageNode: SKSpriteNode? // аватар var user: CloodsUser? var isActive = false } var userGroups = [Int: [UserNode]]() // узлы, сгруппированные по кольцам var radii: [CGFloat]! // радиусы колец
Цвет кружка под аватаром дополнительно кодирует отношение к человеку: друг, подписка, заблокированный пользователь и так далее. Это мелочь, но полезная: часть информации считывается боковым зрением, без необходимости открывать профиль или читать подписи.
Задача: поставить аватары в кольцо без наложений
Первая нетривиальная задача – разложить людей по кольцу так, чтобы аватары не налезали друг на друга.
Можно было бы поставить их равномерно по окружности, но такой вариант выглядит слишком искусственно. Все стоят по линейке, экран становится похож на диаграмму, а не на живую карту людей рядом.
Я сделал проще: воспринимаю кольцо как доступную область. Сначала есть внешний круг, из него вырезается внутренний круг — получается область кольца. Потом каждый поставленный аватар тоже «вырезает» из этой области свой маленький круг. Новую позицию я ищу случайными попытками: до 10 раз пробую найти свободную точку (это, очевидно, далеко не лучший алгоритм, но для MVP сойдет).
// область кольца = внешний круг минус внутренняя дырка var ring = Circle(center: center, radius: radii[level]) ring.removeSubCircle(Circle(center: center, radius: radii[level - 1])) func placeAvatar(_ user: CloodsUser) -> UserNode? { for _ in 0..<10 { let spot = randomPointInRing() let footprint = Circle(center: spot, radius: smallUserRadius) if ring.contains(footprint) { ring.removeSubCircle(footprint) return makeNode(user, at: spot) } } return nil }
Вращение пальцем
Кольца можно крутить пальцем. Пользователь тянет дугу — аватары смещаются по окружности. Отпускает — если движение было достаточно быстрым, кольцо ещё немного доезжает по инерции и плавно останавливается.
Позиция каждого узла пересчитывается через его угол, радиус и текущее смещение:
let x = sin(node.angle + offset) * node.radius let y = (1 - cos(node.angle + offset)) * node.radius node.position = CGPoint( x: centerX + x, y: yCircleCenter + node.radius - y )
Во время движения пальца я считаю угловую скорость. Её обязательно нужно ограничивать, потому что один резкий свайп или некрасивый скачок координат может дать слишком большое значение:
let angleDiff = newAngle - currentAngle let timeDiff = CACurrentMediaTime() - lastMoveTime let velocity = max(-4, min(angleDiff / timeDiff, 4))
После отпускания пальца я проверяю скорость. Если она слишком маленькая, инерцию не запускаю — иначе экран начинает реагировать даже на лёгкое дрожание пальца, и это ощущается неприятно.
override func touchesEnded(...) { let velocity = lastVelocity guard abs(velocity.degrees) > 10 else { return } let duration: CGFloat = 0.7 let destinationAngle = velocity * duration + currentAngle animator.startAnimation( animationDuration: Double(duration), animation: { time in let offset = Math.linearFunction( x: time, leftValue: currentAngle, rightValue: destinationAngle ) reposition(ring: nodes, by: offset) }, timingFunction: { x, lastY in Math.bezierPathValue( x: x, lastY: lastY, startPoint: .zero, cPoint1: CGPoint(x: 58.0 / 200, y: 183.0 / 200), cPoint2: CGPoint(x: 106.0 / 200, y: 1) ) } ) }
animator — это DisplayLinkAnimator из первой статьи серии: небольшой кадровый аниматор поверх CADisplayLink, которому можно передать свою timing-функцию.
Здесь можно было бы попробовать SKPhysicsBody и angularDamping: вроде бы SpriteKit уже игровой движок, физика там есть, но было интереснее реализовать самостоятельно по аналогии с тем, как работает UIScrollView
Виртуализация: узлов много, аватары только у видимых
На сцене могут быть сотни пользователей. Сами SKShapeNode относительно дешёвые, но держать в памяти сотни аватаров как текстуры — плохая идея. Особенно если пользователь активно вращает кольца и часть людей постоянно уезжает за экран.
В UITableView такая задача решается переиспользованием ячеек. В SpriteKit этого механизма из коробки нет, поэтому я сделал простую виртуализацию руками: узел может существовать на сцене всегда, но аватарная текстура загружается только тогда, когда узел попадает в видимую область. Когда он уезжает за край, картинка выгружается.
for node in ring { reposition(node, by: offset) if visibleRect.contains(node.position) { if node.image == nil { node.loadAvatar(inQueue: bwQueue, imageContext: ctx) } } else { node.image = nil node.imageNode?.texture = nil } }
Это прямой аналог reuse, только для SpriteKit-сцены. Я не пересоздаю всю структуру узлов на каждый поворот, а просто управляю тяжёлой частью — текстурами.
В результате память зависит не от общего количества пользователей в сцене, а от количества аватаров, которые реально видны на экране.
Цвет и чёрно-белые аватары
Ещё один визуальный слой — активность. Активные собеседники показываются в цвете, остальные приглушаются до почти чёрно-белого состояния.
Важно, что это не пост-эффект на всю сцену. Аватар обрабатывается отдельно при загрузке, на фоновой очереди, через общий CIContext:
bwQueue.addOperation { let rounded = avatarImage.roundImage(withCrop: true) let bw = rounded.saturation(context: ctx, percent: 0.5) let texture = node.isActive ? SKTexture(image: rounded) : SKTexture(image: bw ?? rounded) let avatar = SKSpriteNode(texture: texture) avatar.setScale(0) node.addChild(avatar) foregroundTask { avatar.run(.scale(to: 1, duration: 0.2)) } }
bwQueue — отдельная OperationQueue с maxConcurrentOperationCount = 10. Несколько аватаров могут обрабатываться параллельно, но главный поток при этом не занимается ни обрезкой, ни изменением насыщенности, ни подготовкой изображения.
CIContext создаётся один раз и переиспользуется. Это важная деталь: создавать новый контекст под каждый аватар — дорогая операция, и на большом количестве картинок она быстро начинает тормозить.
Сама идея простая: неактивные пользователи не исчезают, но становятся менее заметными. Активные остаются цветными и визуально сильнее притягивают взгляд.
Почему не UIKit
Как я уже писал, можно было бы реализовать всё с помощью UIView или даже просто с помощью обычных CALayer. Если смотреть на экран совсем формально, это всё ещё список людей, просто необычный. SpriteKit оказался удобнее не потому, что он чем-то принципиально лучше UIKit, а потому что модель сцены лучше совпала с задачей. Есть узлы, есть позиции, есть кадры, есть простое перемещение объектов, есть понятная работа с текстурами.
То есть я использовал игровой движок не ради игры, а ради интереса и интерфейса, который по поведению ближе к сцене, чем к экрану с ячейками
Итог
Для экрана «люди вокруг» было важно показать не просто профили, отсортированные по расстоянию, а ощущение пространства вокруг пользователя. Поэтому список здесь проигрывал ещё до начала реализации: он был бы проще технически, но хуже передавал бы основную идею.
В итоге SpriteKit дал мне несколько полезных вещей:
свободную раскладку сотен узлов по кольцам;
плавное вращение пальцем;
полный контроль над инерцией через
DisplayLinkAnimator;возможность виртуализировать аватары вручную через
visibleRect;работу с текстурами и фоновую подготовку изображений без привязки к UIKit-иерархии.
На этом серия про проект заканчивается, но после поделюсь уже с некоторыми более сложными вещами: как я добивался точной копии glass-подложки в macOS-приложении, как я разбирал UISlider (одна из самых нетривиальных задач, которую мне доводилось решать) и другими интересными на мой взгляд вещами
