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 на визуальное отображение изображения

Влияние значения 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  }

Разберем, что здесь происходит:

  1. Строка 7. Палец касается View, panGestureAnchorPoint присваивается значение, соответствующее точке касания.

  2. Строки 11-27. Значению gesturePoint соответствует точка, являющаяся результатом перемещения пальца из точки panGestureAnchorPoint в другое положение. Исходя из разницы координат X, Y точек gesturePoint и panGestureAnchorPoint мы рассчитываем поворот изображения по соответствующим осям.
    Далее проводим операцию конкатенации матриц, чтобы получить результирующую трансформацию по осям X и Y.

  3. Строки 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *