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-объект.

1976a5606983c74eef63f8fab0e81c15.gif

Выглядит неплохо, но все же это плоское изображение. Следующим шагом на пути к 3D будет разделение этой картинки на несколько слоев.

STEP 2: Эволюционируем до многослойного интерактивного 2D-изображения

В Figma или любом другом редакторе разделите свое изображение на любое количество слоев.

5bd86e081c56773f2516c4ac53931fb2.png

Отличий от предыдущего решения будет немного:

     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)

После всех изменений в конечном итоге мы получим:

bf41a341c6beba0a9a8fc1d72fe061b9.gif

Это уже намного лучше. Наш логотип приобрел объемность. 

Подробнее это можно увидеть на картинке слева, а справа предыдущий кейс для сравнения. На этом можно было бы остановиться, ведь весьма хороший результат достигнут с минимумом усилий. Но раз уже начали, давайте приступим к SceneKit.

f5cd5fa2462cb4b8ea359dc52aad9200.png

STEP 3: Final. Вращаем 3D-объект

Опустим процесс создания 3D-объекта нашего логотипа и процесс его добавления в проект, так как это подробно описано в одной из статей, упомянутых в начале.

Самым оптимальным решением было выбрано добавить в проект модель логотипа с установленными в Blender цветовыми схемами для каждой грани, а добавление камеры и источников света выполнить программно.

На изображении ниже вы видите наш 3D-логотип и его название в графе сцены (название стандартное, так как объект был создан из цилиндра).

4d996cf70841d709813499ad10b79b25.png

Сначала добавим свойства: 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.

Посмотрим, что у нас получилось:

310819aae90d404aa1d4d560a719aa04.gif

Заключение

Мы разобрали варианты реализации интерактивного логотипа тремя разными способами, которые сделают приложение более привлекательным. Благодаря этому плоское изображение проходит маленькую эволюцию к объемному объекту. Но применение этих технологий не ограничивается только лишь разобранными выше примерами — экспериментируйте!

Ссылка на репозиторий здесь. Копируйте код, настраивайте под себя, изучайте и используйте в своих проектах :)

Спасибо за внимание!

Больше авторских материалов для mobile-разработчиков от моих коллег читайте в соцсетях SimbirSoft — ВКонтакте и Telegram.

© Habrahabr.ru