Step by step: интегрируем интерактивные 2D/3D-объекты в iOS-приложение
Привет, Хабр! Меня зовут Степан, я iOS-разработчик SimbirSoft.
В очередной раз открыв одно из ежедневных приложений, я обнаружил любопытную фичу: интерактивный 3D-элемент в виде звездочки. Казалось бы, ничего необычного, но это сделало взаимодействие с приложением немного приятнее. Так появилась идея для пет-проекта — нативно создать MVP интерактивного 3D-объекта.
Какие преимущества добавит эта фича в ваш продукт?
Повышение визуальной привлекательности
Повышение вовлеченности пользователей
Придание уникальности продукту
Данная статья — step by step от интерактивных 2D-объектов к 3D. В конце статьи вы найдете ссылку на репозиторий.
Чтобы материал был максимально понятным, перед прочтением рекомендую ознакомиться с другими статьями на эту тему
Итак, посмотрим, на что способен Swift!
STEP 1: интерактивное однослойное 2D-изображение
Создаем view и матрицу, относительно которой преобразования будут применяться к нашему view.
var transform3DView = UIView()
let perspective: CGFloat = -1 / 600
lazy var matrix: CATransform3D = {
var transform3D = CATransform3DIdentity
transform3D.m34 = perspective
transform3DView.layer.sublayerTransform = transform3D
return transform3D
}()
Обратите внимание на переменную perspective. Именно она отвечает за создание эффекта объемной трансформации, так называемого эффекта «рыбьего глаза». При увеличении отрицательного значения этот эффект также увеличивается:
В качестве примера: слева perspectiveравно -1 / 10, справа -1 / 500.
Влияние значения perspective на визуальное отображение изображения
Далее для возможности взаимодействия с изображением добавим UIPanGestureRecognizer () и anchor point (точка, в которой произошло касание), чтобы рассчитать трансформации изображения, исходя из пройденного пальцем расстояния.
private let panGestureRecognizer = UIPanGestureRecognizer()
private var panGestureAnchorPoint: CGPoint?
Добавим panGestureRecognizerна transform3DViewи установим параметр maximumNumberOfTouchesравным единице во избежание непредсказуемого поведения.
private func setupGestureRecognizers() {
panGestureRecognizer.addTarget(self, action: selector(handlePanGesture(_:)))
// To avoid bugs, we set the number of touches
panGestureRecognizer.maximumNumberOfTouches = 1
transform3DView.addGestureRecognizer(panGestureRecognizer)
}
Следующим и, наверное, самым важным шагом будет добавление логики, срабатывающей при касании по картинке.
1 @objc private func handlePanGesture(_ gestureRecognizer: UIPanGestureRecognizer) {
2 guard panGestureRecognizer === gestureRecognizer else { return }
3
4 switch gestureRecognizer.state {
5 case .began:
6 panGestureAnchorPoint = gestureRecognizer.location(in: self)
7 case .changed:
8 guard let panGestureAnchorPoint = panGestureAnchorPoint else { return }
9 let gesturePoint = gestureRecognizer.location(in: self)
10
11 // Calculate parameters for the angle in X
12 let ratioMovementXToHalfWidthScreen = (gesturePoint.x - panGestureAnchorPoint.x) / (transform3DViewSize / 2)
13 let angleX: Float = ( .pi / 4 ) * Float(ratioMovementXToHalfWidthScreen)
14 let rotation3dX = CATransform3DRotate(matrix, CGFloat(angleX) , 0, 1, 0)
15
16 // Calculate parameters for the angle in Y
17 let ratioMovementYToHalfWidthScreen = (gesturePoint.y - panGestureAnchorPoint.y) / (transform3DViewSize / 2)
18 let angleY: Float = ( -.pi / 4 ) * Float(ratioMovementYToHalfWidthScreen)
19 let rotation3dY = CATransform3DRotate(matrix, CGFloat(angleY) , 1, 0, 0)
20
21 // Transformation Concatenation operation
22 transform3DView.transform3D = CATransform3DConcat(rotation3dX, rotation3dY)
23
24 case .cancelled, .ended, .failed, .possible:
25 refreshView(duration: 1.5)
26 panGestureAnchorPoint = nil
27
28 @unknown default:
29 break
30 }
31 }
Разберем, что здесь происходит:
Строка 7. Палец касается View, panGestureAnchorPointприсваивается значение, соответствующее точке касания.
Строки 11–27. Значению gesturePointсоответствует точка, являющаяся результатом перемещения пальца из точки panGestureAnchorPointв другое положение. Исходя из разницы координат X, Y точек gesturePointи panGestureAnchorPointмы рассчитываем поворот изображения по соответствующим осям.
Далее проводим операцию конкатенации матриц, чтобы получить результирующую трансформацию по осям X и Y.Строки 29–30. По завершению касания возвращаем изображение в исходное положение функцией refreshMView (duration: 1.5) и присваиваем panGestureAnchorPointзначение nil.
А теперь еще чуть подробнее разберем строки 14–16:
14 let ratioMovementXToHalfWidthScreen = (gesturePoint.x - panGestureAnchorPoint.x) / (transform3DViewSize / 2)
15 let angleX: Float = ( .pi / 4 ) * Float(ratioMovementXToHalfWidthScreen)
16 let rotation3dX = CATransform3DRotate(matrix, CGFloat(angleX) , 0, 1, 0)
Итак, максимальный угол, на который может повернуться изображение, при условии начала касания в центре картинки и завершении касания у ее границы, равен .pi / 4, то есть 45 градусов. Данное значение задается в строке 15.
В строке 14 мы рассчитываем коэффициент, равный отношению длины пути, пройденного пальцем из точки panGestureAnchorPointв точку gesturePoint, к половине ширины изображения. С помощью этого коэффициента в строке 15 рассчитываем угол поворота.
И в строке 16, используя рассчитанный угол, создаем трансформацию CATransform3DRotate.
Почему мы создаем трансформацию именно так, подскажет официальное описание данного метода: «Rotates t by angle radians about the vector (x, y, z) and returns the result».
func CATransform3DRotate(
_ t: CATransform3D,
_ angle: CGFloat,
_ x: CGFloat,
_ y: CGFloat,
_ z: CGFloat
) -> CATransform3D
Завершающим шагом будет создание анимации возвращения объекта в начальное состояние:
1 private func refreshView(duration: Double) {
2 let transform3D = CATransform3DIdentity
3 transform3D.m34 = perspective
4
5 UIView.animate(
6 withDuration: duration,
7 delay: 0,
8 usingSpringWithDamping: 0.35,
9 initialSpringVelocity: 0.3,
10 options: [.allowUserInteraction]
11 ) {
12 self.transform3DView.transform3D = transform3D
13 }
14 }
В строке 2 мы создаем матрицу начального состояния CATransform3DIdentity, и в блоке анимации присваиваем эту матрицу нашему изображению.
Для возможности взаимодействия с изображением в процессе его анимации выставляем в блоке optionsпараметр [.allowUserInteraction]. В противном случае наше изображение будет заблокировано до окончания анимации.
Готово! Мы создали интерактивный 2D-объект.
Выглядит неплохо, но все же это плоское изображение. Следующим шагом на пути к 3D будет разделение этой картинки на несколько слоев.
STEP 2: Эволюционируем до многослойного интерактивного 2D-изображения
В Figma или любом другом редакторе разделите свое изображение на любое количество слоев.
Отличий от предыдущего решения будет немного:
var layerLevel1 = CALayer()
var layerLevel2 = CALayer()
var layerLevel3 = CALayer()
var transformLayer = CATransformLayer()
Класс CATransformLayer () используется для создания многоуровневой иерархии слоев. Подробнее на странице официальной документации. Добавляем слой transformLayer на основную view и применяем к ней нашу матрицу преобразования:
caTransform3DView.layer.addSublayer(transformLayer)
caTransform3DView.transform3D = matrix
private func configureLayers() {
let imageViewArray = [
(layerLevel1, "level1"),
(layerLevel2, "level2"),
(layerLevel3, "level3")
]
imageViewArray.forEach { (layer, imgName) in
let image = UIImage(named: imgName)?.cgImage
layer.contents = image
layer.contentsGravity = CALayerContentsGravity.resize
layer.allowsEdgeAntialiasing = true
layer.drawsAsynchronously = true
layer.isDoubleSided = true // значение отвечает за отрисовку обратной стороны слоя
layer.backgroundColor = UIColor.clear.cgColor
layer.frame = CGRect(
x: 0,
y: 0,
width: transform3DViewSize,
height: transform3DViewSize
)
}
layerLevel1.zPosition = 0
layerLevel2.zPosition = 20
layerLevel3.zPosition = 35
transformLayer.addSublayer(layerLevel1)
transformLayer.addSublayer(layerLevel2)
transformLayer.addSublayer(layerLevel3)
transformLayer.frame = CGRect(
x: 0,
y: 0,
width: transform3DViewSize,
height: transform3DViewSize
)
}
Если в прошлый раз мы применяли преобразования к нашей view, то в этом случае мы меняем слои. И, соответственно, в блоке анимации метода возвращения слоев к изначальному положению у нас теперь будет так:
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: 0.35,
initialSpringVelocity: 0.3,
options: [.allowUserInteraction]
) {
self.caTransform3DView.layer.sublayerTransform = transform3D
}
А в блоке кода, отвечающего за логику обработки жестов, мы изменим нашу операцию конкатенации трансформаций на это:
// Transformation Concatenation operation
self.caTransform3DView.layer.sublayerTransform = CATransform3DConcat(rotation3dX, rotation3dY)
После всех изменений в конечном итоге мы получим:
Это уже намного лучше. Наш логотип приобрел объемность.
Подробнее это можно увидеть на картинке слева, а справа предыдущий кейс для сравнения. На этом можно было бы остановиться, ведь весьма хороший результат достигнут с минимумом усилий. Но раз уже начали, давайте приступим к SceneKit.
STEP 3: Final. Вращаем 3D-объект
Опустим процесс создания 3D-объекта нашего логотипа и процесс его добавления в проект, так как это подробно описано в одной из статей, упомянутых в начале.
Самым оптимальным решением было выбрано добавить в проект модель логотипа с установленными в Blender цветовыми схемами для каждой грани, а добавление камеры и источников света выполнить программно.
На изображении ниже вы видите наш 3D-логотип и его название в графе сцены (название стандартное, так как объект был создан из цилиндра).
Сначала добавим свойства: SCNView (подкласс UIView, предоставляющий место под объекты SceneKit в пользовательском интерфейсе нашего приложения), сцену SCNScene, камеру (точка обзора), источники света и матрицу преобразования SCNMatrix4.
var sceneView = SCNView(frame: .zero)
var scnScene: SCNScene!
var camera: SCNNode!
var ambientLight: SCNNode!
var spotLightBottomRight: SCNNode!
var spotLightBottomLeft: SCNNode!
var spotLightFront: SCNNode!
lazy var matrix: SCNMatrix4 = {
var matrix = SCNMatrix4Identity
return matrix
}()
Так же, как и в предыдущих решениях, нам понадобится UIPanGestureRecognizer () и panGestureAnchorPoint.
Далее создадим методы, в которых настроим сцену, позицию логотипа, источники обзора и света, и вызовем их в viewDidLoad ().
private func setupScene() {
scnScene = SCNScene(named: Constants.sceneName)
// Настраиваем позицию и ориентацию логотипа
let ssLogoNode = scnScene.rootNode.childNode(withName: Constants.logoName, recursively: true)!
ssLogoNode.position = SCNVector3(x: 0, y: 0, z: 0)
ssLogoNode.eulerAngles = SCNVector3(
x: GLKMathDegreesToRadians(0),
y: GLKMathDegreesToRadians(0),
z: GLKMathDegreesToRadians(0)
)
ssLogoNode.scale = SCNVector3(x: 1, y: 1, z: 1)
sceneView.scene = scnScene
}
private func setupCamera() {
camera = SCNNode()
camera.camera = SCNCamera()
// Настраиваем позицию и ориентацию камеры
camera.eulerAngles = SCNVector3Make(
GLKMathDegreesToRadians(-90),
GLKMathDegreesToRadians(90),
GLKMathDegreesToRadians(0)
)
camera.position = SCNVector3Make(0, 5, 0)
camera.scale = SCNVector3Make(1, 1, 1)
camera.camera?.usesOrthographicProjection = true
camera.camera?.orthographicScale = 2.5
sceneView.scene?.rootNode.addChildNode(camera)
sceneView.pointOfView = camera
}
В методе setupCamera обратите внимание на свойство userOrthographicProjection, оно отвечает за отображение нашего объекта без применения эффекта перспективы. На изображении ниже представлен наш объект при значении userOrthographicProjection = false (слева) и userOrthographicProjection = true (справа).
Реализацию метода настройки источников света опустим, так как там все довольно очевидно. Вы можете подробнее ознакомиться с ним в репозитории.
Далее напишем логику, срабатывающую при касании по SCNView.
1 @objc private func handlePanGesture(_ gestureRecognizer: UIPanGestureRecognizer) {
2 guard panGestureRecognizer === gestureRecognizer else { return }
3 let ssLogoNode = sceneView.scene!.rootNode.childNode(withName: Constants.logoname, recursively: true)
4
5 switch gestureRecognizer.state {
6 case .began:
7 matrix = ssLogoNode!.presentation.transform
8 panGestureAnchorPoint = gestureRecognizer.location(in: sceneView)
9
10 case .changed:
11 ssLogoNode?.removeAllAnimations()
12
13 guard let panGestureLocation = panGestureAnchorPoint else { return }
14 let gesturePoint = gestureRecognizer.location(in: sceneView)
15
15 let ratioMovementXToHalfWidthScreen = (gesturePoint.x - panGestureLocation.x) / (sceneView.bounds.width / 2)
17 let angleX: Float = ( -.pi / 2 ) * Float(ratioMovementXToHalfWidthScreen)
18 let rotate3dX = SCNMatrix4Rotate(matrix, angleX, 1, 0, 0)
19
20 let ratioMovementYToHalfHeightScreen = (gesturePoint.y - panGestureLocation.y) / (sceneView.bounds.height / 2)
21 let angleY: Float = ( -.pi / 4 ) * Float(ratioMovementYToHalfHeightScreen)
22 let rotate3dY = SCNMatrix4Rotate(matrix, angleY, 0, 0, 1)
23
24 ssLogoNode!.transform = SCNMatrix4Mult(rotate3dX, rotate3dY)
25
26 case .cancelled, .ended, .failed, .possible:
27 refreshNode(duration: 1.5, node: ssLogoNode!)
28 panGestureAnchorPoint = nil
29
30 @unknown default:
31 break
32 }
33 }
Как видите, по сравнению с предыдущими решениями отличий немного, лишь поменялись названия. Все те же операции создания трансформаций и конкатенации матриц.
Пройдемся по отличиям от предыдущих решений:
Строка 3. Для применения трансформации к объекту мы должны выбрать его по имени из объектов сцены.
Строка 7. Перед применением трансформаций в случае, если наш объект в данный момент анимируется, мы приравниваем переменную matrix к текущему положению объекта, выраженному в виде четырехмерной матрицы.
Строка 12. Если объект в данный момент анимируется, перед применением трансформаций нужно удалить все анимации с объекта.
И в завершение напишем метод анимированного возвращения объекта в исходное состояние:
private func refreshNode(duration: Double, node: SCNNode) {
let identityMatrix = SCNMatrix4Identity
let springAnimation = CASpringAnimation(keyPath: "transform")
springAnimation.fromValue = node.transform
springAnimation.toValue = identityMatrix
springAnimation.damping = 6
springAnimation.initialVelocity = 0.3
springAnimation.fillMode = .forwards
springAnimation.isRemovedOnCompletion = false
springAnimation.duration = duration
springAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
node.addAnimation(springAnimation, forKey: "transform")
}
В отличие от предыдущих решений, здесь мы не можем использовать анимации класса UIView, но можем задать неявную анимацию фреймворка CoreAnimation.
Посмотрим, что у нас получилось:
Заключение
Мы разобрали варианты реализации интерактивного логотипа тремя разными способами, которые сделают приложение более привлекательным. Благодаря этому плоское изображение проходит маленькую эволюцию к объемному объекту. Но применение этих технологий не ограничивается только лишь разобранными выше примерами — экспериментируйте!
Ссылка на репозиторий здесь. Копируйте код, настраивайте под себя, изучайте и используйте в своих проектах :)
Спасибо за внимание!
Больше авторских материалов для mobile-разработчиков от моих коллег читайте в соцсетях SimbirSoft — ВКонтакте и Telegram.