Серия, посвященная воссозданию классных демок пользовательского интерфейса, на которые я наткнулся на просторах интернета. Сегодня мы реализуем морфинг между разными формами иконок и разберемся, что такое Metaballs.

На днях в Твиттере я наткнулся на твит, демонстрирующий одну классную технику в пользовательском интерфейсе, и заинтересовался, как она была реализована.

Увидев этот твит, я вспомнил об идее, вертевшейся у меня в голове на протяжении нескольких лет. Идея заключалась в том, чтобы написать серию статей, посвященных воссозданию различных классных штук из концептуальных видео, постов на Dribbble или необычных элементов пользовательского интерфейса из других приложений.

Итак, в качестве первой статьи из (я надеюсь) большой серии, давайте разберемся, как создать классный компонент пользовательского интерфейса: “Shape Morphing” на iOS.

Metaballs

Техника реализации объектов, органически морфирующих между собой и друг в друга, известна как metaballs (или метасферы).

Когда два объекта приближаются друг к другу, вместо того, чтобы спокойно ждать, пока они начнут перекрываться… 

… эти два объекта начнут растягиваться друг к другу и объединяться. 

Реализация в iOS

Чтобы реализовать этот эффект, нам нужно объединить вместе 2 разных эффекта (по крайней мере, в рамках того способа, которым мы собираемся это сделать, но есть еще несколько других способов добиться этого эффекта, ссылки на которые приведены в конце статьи).

Нам нужно отрендерить наши формы, а затем применить поверх отрендеренного изображения эти эффекты в рамках этапа постобработки.

Кажется у CALayer есть свойство .filters которое идеально подходит для этого!

Массив Core Image фильтров применяемых к содержимому слоя и его подслоям. Animatable.

Пока вы не доберетесь до низа документации.

Это свойство не поддерживается для слоев в iOS

К сожалению, такие эффекты рендеринга не поддерживаются в iOS.

Поэтому вместо этого нам нужно использовать API для рендеринга, который позволяет нам делать постобработку результата рендеринга.

В iOS у нас есть выбор между SpriteKit и SceneKit.

В SceneKit есть крутой трюк, позволяющий вам установить UIView в качестве материала узла, что было бы очень полезно при применении этого трюка к UIView, поскольку мы могли бы размещать представления внутри представления SceneKit.

Однако пока я просто буду использовать SpriteKit, так как мне не нужно ничего большего для этого примера, и с ним проще работать.

Реализация со SpriteKit

Сначала нам нужно подготовить нашу сцену:

class SimpleViewController: UIViewController {
	// Наши SpriteKit объекты 
	let skView = SKView()
	let scene = SKScene()
	// Две сферы
	var blobOne: SKShapeNode? = nil
	var blobTwo: SKShapeNode? = nil
	override func viewDidLoad() {
		super.viewDidLoad()
		// Подготовка сцены
		self.scene.scaleMode = .resizeFill
		self.scene.physicsWorld.gravity = CGVector(dx: 0, dy: 0)
		self.skView.presentScene(self.scene)
		self.view.addSubview(self.skView);
		// Создаем две сферы
		{
			let blob = SKShapeNode(circleOfRadius: 50)
			blob.fillColor = UIColor.yellow
			blob.strokeColor = blob.fillColor
			self.scene.addChild(blob)
			self.blobOne = blob
		}();
		{
			let blob = SKShapeNode(circleOfRadius: 50)
			blob.fillColor = UIColor.white
			blob.strokeColor = blob.fillColor
			self.scene.addChild(blob)
			self.blobTwo = blob
		}();
	}
	// Устанавливаем позиции всех элементов
	override func viewDidLayoutSubviews() {
		super.viewDidLayoutSubviews()
		let bounds = self.view.bounds
		self.skView.frame = bounds
		let center = CGPoint(x: bounds.midX, y: bounds.midY)
		self.blobOne?.position = center.applying(CGAffineTransform(translationX: -50, y: 0))
		self.blobTwo?.position = center.applying(CGAffineTransform(translationX: 50, y: 0))
	}
}

 Это наш базовый сетап. Две сферы расположены рядом друг с другом, едва соприкасаясь.

Теперь давайте добавим эффект размытия в конце -viewDidLoad().

// - В начало файла....
import CoreImage
import CoreImage.CIFilterBuiltins
// - Внизу viewDidLoad
// Создаем фильтр
let blur = CIFilter.gaussianBlur()
blur.radius = 20
self.scene.filter = blur
// Убедитесь, что сцена использует созданный нами фильтр
self.scene.shouldEnableEffects = true

 

Теперь, когда мы это сделали, мы можем видеть, как фигуры сливаются в одну благодаря перекрытию их размытых версий.

Однако это все еще не похоже на тот эффект, который нам нужен. Для этого нам нужно будет применить фильтр “порог” (threshold), где мы делаем полностью непрозрачными все, что выше определенного порога, и скрываем каждый пиксель ниже этого порога.

Это позволит нашим формам сохранить четкие границы и выглядеть соединяющимися, а не просто сливающимися друг с другом.

Мы не можем применять несколько фильтров к SpriteKit сцене, поэтому нам нужно создать класс, наследующий CIFilter, и создать собственный комбинированный фильтр.

class MetaballEffectFilter: CIFilter
{
	// Внутренние фильтры
	let blurFilter: CIFilter & CIGaussianBlur = {
		let blur = CIFilter.gaussianBlur()
		blur.radius = 30
		return blur
	}()
	let thresholdFilter = LumaThresholdFilter()
	// - Свойства дочернего класса CIFilter
	@objc dynamic var inputImage : CIImage?
	override var outputImage: CIImage!
	{
		guard let inputImage = self.inputImage else
		{
			return nil
		}
		// Размытие изображения
		self.blurFilter.inputImage = inputImage
		let blurredOutput = self.blurFilter.outputImage
		// Обрезка по определенному порогу 
		self.thresholdFilter.inputImage = blurredOutput
		return self.thresholdFilter.outputImage
	}
}

 Сначала мы размываем изображение, затем обрезаем вывод по пороговому значению.

Но откуда взялся этот LumaThresholdFilter ? Это еще один класс, который нам нужно реализовать самим.

Наиболее простой способ сделать это — использовать Core Image Kernel Language API, который устарел. Вместо этого нам следует использовать новый Metal Shader CoreImage Filter API.

Однако для создания металлических шейдеров CoreImage требуется еще несколько шагов по настройке конфигурации сборки путем добавления пользовательских правил и параметров сборки. Поэтому сейчас я покажу примеры как металлических шейдеров, так и шейдеров CoreImage, но позже буду использовать только Core Image Kernel Language для остальных примеров.

Metal Shader

// LumaThreshold.ci.metal

#include <metal_stdlib>
using namespace metal;
#include <CoreImage/CoreImage.h>

extern "C" float4 lumaThreshold(coreimage::sample_t pixelColor, float threshold, coreimage::destination destination)
{
	float3 pixelRGB = pixelColor.rgb;
	
	float luma = (pixelRGB.r * 0.2126) + (pixelRGB.g * 0.7152) + (pixelRGB.b * 0.0722);
	
	return (luma > threshold) ? float4(1.0, 1.0, 1.0, 1.0) : float4(0.0, 0.0, 0.0, 0.0);
}

 Core Image Kernel Language Shader

// LumaThresholdFilter.swift

class LumaThresholdFilter: CIFilter
{
	var threshold: CGFloat = 0.5
	
	static let thresholdKernel = CIColorKernel(source:"""
kernel vec4 thresholdFilter(__sample image, float threshold)
{
	float luma = (image.r * 0.2126) + (image.g * 0.7152) + (image.b * 0.0722);
	return (luma > threshold) ? vec4(1.0, 1.0, 1.0, 1.0) : vec4(0.0, 0.0, 0.0, 0.0);
}
""")!
	
	//
	
	@objc dynamic var inputImage : CIImage?
	
	override var outputImage : CIImage!
	{
		guard let inputImage = self.inputImage else
		{
			return nil
		}
		
		let arguments = [inputImage, Float(self.threshold)] as [Any]
		
		return Self.thresholdKernel.apply(extent: inputImage.extent, arguments: arguments)
	}
	
}

Мы наконец получили желаемый эффект:

 

От метасфер к морфингу иконок

Чтож, мы добились эффекта метасфер. Но это еще не морфинг иконок, как показано в оригинальном видео в Твиттере. Однако теперь у нас есть все необходимое.

Теперь нам просто нужно добавить новую иконку поверх старой, при этом постепенно убирая старую иконку.

Для начала нам нужно поменять два круга на одну иконку. Теперь нам также нужно сохранить ссылку на фильтр, чтобы мы могли его анимировать.

var currentIcon: SKSpriteNode?

let filter = MetaballEffectFilter()

И давайте добавим несколько кнопок на экран, чтобы мы могли их задействовать.

// Свойство

lazy var buttons : [UIButton] = { [unowned self] in
	return [
		"circle.fill",
		"heart.fill",
		"star.fill",
		"bell.fill",
		"bookmark.fill",
		"tag.fill",
		"bolt.fill",
		
		"play.fill",
		"pause.fill",
		"squareshape.fill",
		"key.fill",
		"hexagon.fill",
		"gearshape.fill",
		"car.fill",
	].map({ buttonName in
		
		var config = UIButton.Configuration.filled()
		config.image = UIImage(systemName: buttonName)
		config.baseBackgroundColor = UIColor.white
		config.baseForegroundColor = UIColor.black
		
		let button = UIButton(configuration: config)
		
		button.addAction(UIAction(handler: { [weak self] _ in
			self?.animateIconChange(newIconName: buttonName, duration: 0.5)
		}), for: .touchUpInside)
		
		return button
	})
}()


// ...in viewDidLoad()

for button in self.buttons {
	self.view.addSubview(button)
}


// .. In viewDidLayoutSubviews()

let buttonSize: CGSize = CGSize(width: 70, height: 50)

var x: CGFloat = 0
var y: CGFloat = bounds.height - (self.view.safeAreaInsets.bottom + 5 + buttonSize.height)

for button in self.buttons {
	button.bounds = CGRect(origin: .zero, size: buttonSize)
	button.center.x = x + (buttonSize.width * 0.5)
	button.center.y = y + (buttonSize.height * 0.5)
	
	x += buttonSize.width + 5
	
	if x > bounds.size.width - (buttonSize.width + 5) {
		x = 0
		y -= buttonSize.height + 5
	}
}

Теперь давайте настроим отображение иконки. (Используем этот UIImage хелпер).

// ...в конце viewDidLoad()

self.animateIconChange(newIconName: "circle.fill", duration: 0)


// Создаем новый метод

func animateIconChange(newIconName: String, duration: CGFloat, showPlayIcon: Bool = false) {
	// Создаем новую форму для иконки
	let newIconShape: SKSpriteNode? = {
		let iconSize = CGSize(width: 80, height: 80)
		
		guard let image = UIImage(systemName: newIconName)?.withTintColor(UIColor.white).resized(within: iconSize) else { return nil }
		
		let texture = SKTexture(image: image)
		let sprite = SKSpriteNode(texture: texture, size: iconSize)
		return sprite
	}()
	
	newIconShape?.position = CGPoint(
		x: self.view.bounds.midX,
		y: self.view.bounds.midY
	)
	newIconShape?.alpha = 0
	
	// Добавляем новую иконку
	if let newIconShape = newIconShape {
		self.scene.addChild(newIconShape)
	}
	
	let oldIconShape = self.currentIcon
	self.currentIcon = nil
	
	self.currentIcon = newIconShape
	
	
	if duration == 0 {
		newIconShape?.alpha = 1
		oldIconShape?.removeFromParent()
		return
	}

	...
}

Теперь у нас есть базовая структура.

Чтобы анимировать смену иконок аналогично тому, как они появляются в Твиттере, нам нужно быстро выполнить следующую последовательность:

  • Сначала анимируем размытие от 0 до какого-либо приемлемого значения.

  • На половине этого процесса начинаем увеличивать альфа-канал новой формы и понижать у старой формы.

  • Примерно на третей четверти пути формы заменяются одна другой, а мы анимируем размытие обратно до 0, чтобы к концу у нас осталась только новая целевая форма.

// ... продолжаем
	
	// Анимируем изменения
	
	let fadeDuration = (duration * 0.25)
	
	// Анимируем эффект размытия
	self.animateBlur(duration: fadeDuration, blur: 5, from: 0)
	
	// Немного ждем, а затем начинаем постепенно отображать новую иконку и скрывать старую
	DispatchQueue.main.asyncAfter(deadline: .now() + (fadeDuration * 0.75), execute: {
		
		let swapDuration = duration * 0.5
		
		newIconShape?.run(SKAction.fadeAlpha(to: 1, duration: swapDuration))
		oldIconShape?.run(SKAction.fadeAlpha(to: 0, duration: swapDuration))
		
		// Снова ждем, а затем начинаем возвращать представление обратно к четкой версии
		DispatchQueue.main.asyncAfter(deadline: .now() + (swapDuration * 0.75), execute: {
			
			self.animateBlur(duration: fadeDuration, blur: 0, from: 5)
			
			// Наводим порядок
			DispatchQueue.main.asyncAfter(deadline: .now() + fadeDuration, execute: {
				oldIconShape?.removeFromParent()
			})
		})
	})
}

// Хелпер, который анимирует размытие
func animateBlur(duration: CGFloat, blur targetBlur: CGFloat, from: CGFloat) {
	let blurFade = SKAction.customAction(withDuration: duration, actionBlock: { (node, elapsed) in
		let percent = elapsed / CGFloat(duration)
		
		let difference = (targetBlur - from)
		let currentBlur = from + (difference * percent)
		
		self.filter.blurFilter.setValue(currentBlur, forKey: kCIInputRadiusKey)
		self.scene.shouldEnableEffects = true
	})
	
	self.scene.run(blurFade)
}

По мере того, как различные части фигур исчезают и превышают пороговое значение, они проявляются.

Размытие также означает, что формы как бы сливаются вместе, как мы видели это ранее в варианте с метасферами.

Мы по сути плавно переходим от одной иконки к другой, используя эффект метасфер для перехода.

Результат потрясающий!

Вы можете изменить ощущение эффекта, изменив скорость и наложение различных частей анимации; и тем, насколько сильно размытые проявляется во время анимации.

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

Полезные ссылки


Приглашаем всех желающих на открытый урок «Современное KMM приложение». На нашем открытом занятии посмотрим, как превратить Android в iOS, мигрировав с помощью KMM, и какие подводные камни встречаются. Регистрация доступна по ссылке.

Комментарии (3)


  1. nikita_dol
    10.08.2022 15:42
    +3

    Результат потрясающий!

    Было бы здорово добавить больше гивок с (промежуточным) результатом


    1. egbad
      11.08.2022 21:49
      +1

      И вначале каждой вставки кода приписать swift, а то без подсветки синтаксиса тяжко читать


  1. NineNineOne
    12.08.2022 00:35

    Не пробовали создать ту же фигуру при помощи SwiftUI?

    Понимаю, что там ограничение на iOS 13 +, но все же интересно.