Creating Cool UI: iOS Shape Morphing

Recreating cool UI demos I've seen online. This time, morphing between different icon shapes and learning about metaballs.

The other day on Twitter I came across a tweet showing a really cool UI demo and wondered how it was done.

Seeing this tweet reminded me of an idea I've had for several years to do a blog post series showing how to actually create various cool UI concept videos, Dribbble posts, or weird UI elements from other apps.

So, as hopefully the first of many, let's see how to a Create Cool UI Component: "Shape Morphing" on iOS.

Metaballs

The technique for organic looking objects morphing between and into each other is known as metaballs.

As two objects get close together, instead of waiting until they start to overlap.

The two objects will instead start stretching towards each other and combining.

iOS Implementation

To implement this effect we need to combine 2 different effects together (at least the way we are going to go about it, there are a few other ways to achieve this effect; which are linked at the bottom of the page).

We need to render our shapes and then apply these effects over the top as post processing steps to the rendered image.

CALayer's do have a .filters property which looks perfect for this!

An array of Core Image filters to apply to the contents of the layer and its sublayers. Animatable

Until you get to the bottom of the documentation.

This property is not supported on layers in iOS

🥲

Sadly such rendering effects aren't supported on iOS.
So instead we'll need to use a rendering API which does let us post-process the rendered result.

On iOS we have the choice of either SpriteKit or SceneKit.

SceneKit has a cool trick of letting you set a UIView as the material of a node, which would be really helpful in applying the trick to UIView's as we could host the views inside the SceneKit view.

However, for now I'll just use SpriteKit as I don't need anything more for this example and it is simpler to work with.

SpriteKit Implementation

First we need to setup our scene:

class SimpleViewController: UIViewController {
	
	// Our SpriteKit Objects
	let skView = SKView()
	let scene = SKScene()
	
	// The two balls
	var blobOne: SKShapeNode? = nil
	var blobTwo: SKShapeNode? = nil
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
		// Setup Scene
		self.scene.scaleMode = .resizeFill
		self.scene.physicsWorld.gravity = CGVector(dx: 0, dy: 0)
		
		self.skView.presentScene(self.scene)
		self.view.addSubview(self.skView);
		
		// Create the 2 balls
		{
			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
		}();
	}
	
	// Set the positions of everything
	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))
	}
}

This gives us our basic scene. Two spheres near each other but only just touching.

Now let's add a blur effect at the end of -viewDidLoad().

// - At the top of the file....
import CoreImage
import CoreImage.CIFilterBuiltins

// - At the bottom of viewDidLoad

// Create 
let blur = CIFilter.gaussianBlur()
blur.radius = 20
self.scene.filter = blur

// Make sure the scene uses the filter we created
self.scene.shouldEnableEffects = true

Now that we've done that we can see how the shapes are merged together as their blurs overlap and merge.

However, this still doesn't look like the effect we want. To achieve that we will need to apply a threshold filter where we make fully opaque everything over a certain threshold, and we hide every pixel below that.
This will make sure our shapes still have precise boundaries and appear to merge, not just blur together.

We can't apply multiple filters to the SpriteKit scene, so we'll need to subclass CIFilter and make our own combined filter.

class MetaballEffectFilter: CIFilter
{
	// Internal Filters
	
	let blurFilter: CIFilter & CIGaussianBlur = {
		let blur = CIFilter.gaussianBlur()
		blur.radius = 30
		return blur
	}()
	
	let thresholdFilter = LumaThresholdFilter()
	
	
	// - CIFilter Subclass Properties
	
	@objc dynamic var inputImage : CIImage?
	
	override var outputImage: CIImage!
	{
		guard let inputImage = self.inputImage else
		{
			return nil
		}
		
		// Blur Image
		self.blurFilter.inputImage = inputImage
		let blurredOutput = self.blurFilter.outputImage
		
		// Clip to the threshold set
		self.thresholdFilter.inputImage = blurredOutput
		return self.thresholdFilter.outputImage
	}
	
}

First we blur the image, then clip the output to the threshold.
But where does this LumaThresholdFilter come from? This is another class we'll need to make ourselves.

The easier way to do this is to use Core Image Kernel Language API which is deprecated. Instead you are meant to use the new metal shader CoreImage filter API.
However, to make CoreImage metal shaders requires several more steps of tweaking your build config by adding custom build rules and build settings. So for now I'll show examples of both the metal and CoreImage shaders but will use the Core Image Kernel Language for the rest of the examples afterwards.

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)
	}
	
}

This now gives us our final effect:

From Metaballs to Icon Morphing

Now we have our metaballs effect. But we still don't have the same icon morphing as shown in the original Twitter video. However, we now have everything we need.

Now we just need to fade in the new icon over the top of the old icon, while fading the old icon out.

First we need to swap the 2 circles for 1 icon. We'll also need to keep hold of the filter reference this time, so we can animate it.

var currentIcon: SKSpriteNode?

let filter = MetaballEffectFilter()

And let's add some button on screen for us to use.

// Property

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
	}
}

Now let's setup showing the icon. (Uses this UIImage helper).

// ...at the end of viewDidLoad()

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


// Create new method

func animateIconChange(newIconName: String, duration: CGFloat, showPlayIcon: Bool = false) {
	// Create new icon shape
	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
	
	// Add new icon
	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
	}

	...
}

Now we have our basic structure.

To animate the change similarly to how it appears on Twitter we want to do several things in quick succession:

  • First animate the blur from 0 to something.
  • Halfway through that, start alpha fading in the new shape and out the old shape.
  • About 3/4rd way through the shapes fading from one to the other, then animate the blur back down to 0 so by the end you end up left with just the new target shape itself.
	// ... continues
	
	// Animate the change
	
	let fadeDuration = (duration * 0.25)
	
	// Animate in the blur effect
	self.animateBlur(duration: fadeDuration, blur: 5, from: 0)
	
	// Wait then start fading in the new icon and out the old
	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))
		
		// Wait, then start returning the view back to a non-blobby version
		DispatchQueue.main.asyncAfter(deadline: .now() + (swapDuration * 0.75), execute: {
			
			self.animateBlur(duration: fadeDuration, blur: 0, from: 5)
			
			// Cleanup
			DispatchQueue.main.asyncAfter(deadline: .now() + fadeDuration, execute: {
				oldIconShape?.removeFromParent()
			})
		})
	})
}

// Helper which animates the blur
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)
}

As the different parts of the shapes fade in and go over the threshold they appear.
The blur also means the shapes sort of meld together as we sort earlier with the metaballs effect itself.

We basically fade from one icon to the other while animating the metaballs effect on and then back off again.

The result:

You can change the feel of the effect by changing the speed and overlap of different parts of the animation; and how much blur you fade in during the animation.
But I've found those values to look quite nice and match the style of the effect in the original video reasonably well.

Useful Links