Привет, Хабр! Меня зовут Степан, я 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.
Далее для возможности взаимодействия с изображением добавим 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 или любом другом редакторе разделите свое изображение на любое количество слоев.

Отличий от предыдущего решения будет немного:
-
Для отображения нескольких слоев на view добавим:
var layerLevel1 = CALayer() var layerLevel2 = CALayer() var layerLevel3 = CALayer() var transformLayer = CATransformLayer()
Класс CATransformLayer() используется для создания многоуровневой иерархии слоев. Подробнее на странице официальной документации. Добавляем слой transformLayer на основную view и применяем к ней нашу матрицу преобразования:
caTransform3DView.layer.addSublayer(transformLayer) caTransform3DView.transform3D = matrix
-
Добавляем метод, в котором настраиваем наши слои и вызываем его в viewDidLoad():
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.
ссылка на оригинал статьи https://habr.com/ru/articles/790294/
Добавить комментарий