- Advertising
- App Monetization
- iOS
- iOS Ad Mediation
- MOBILE
You’ve probably seen those typical UIKit Dynamics tutorials on the Internet: “Let’s add a view, now we add gravity, and… it falls! Then, let’s add a boundary in the bottom of the screen, and it is not falling off screen anymore! Yay!” Let’s be honest though: how often do you need falling views? Most probably, the answer is: “never.” Usually, tutorials present UIKit Dynamics as a pretty robust physics engine. It’s always a pleasure to add an electric charge to your controls and see what will happen, but actually, UIKit Dynamics allows you to create a lot of interesting things even without much physics. So, as a proof, I’ll create a rotating circular menu. Retro is always trendy, so let’s remind users about those good old phone dials.
But if you don’t need tutorials at all and looking for a team of experienced engineers – just book a call with us.
Success!
We’ll reach out to schedule a call
I’ll use Swift playgrounds. You can download a complete playground code from GitHub. In this tutorial, I assume that you are familiar with Swift, UIKit, and UIKit Dynamics basics.
Let’s start with creating some utility functions. I’ll use a Sources folder in the Playground because large source code files are hard to read for other humans, and separating code into a few smaller files is always a good idea. So, we need a file named Helpers.swift
. It will contain two pretty self-explainable functions.
import Foundation
import UIKit
func rndColor() -> UIColor {
let red = CGFloat(arc4random_uniform(255))/255.0
let green = CGFloat(arc4random_uniform(255))/255.0
let blue = CGFloat(arc4random_uniform(255))/255.0
return UIColor(red: red, green: green, blue: blue, alpha: 1)
}
func distanceBetween(p1 : CGPoint, p2 : CGPoint) -> CGFloat {
let dx : CGFloat = p1.x - p2.x
let dy : CGFloat = p1.y - p2.y
return sqrt(dx \* dx + dy \* dy)
}
So, one function to get a random color, and another to get a distance between two points.
Now we need some code to represent menu item per se. For this tutorial, we will use a simple circle with a caption label. To describe an element, we’ll subclass a UIView. In more complex examples, an item can be a class, conforming to some custom protocol, exposing a view property, which will represent menu item. But in a tutorial let’s keep it simple.
This code is pretty self-explanatory too. We initialize our view with the frame of predefined size, add a label in the view’s center and create a subtle shadow. One of the most tricky things here is an autoresizingMask
property setting. As I am planning to resize these views, it’s always a good idea to delegate this job to UIKit.
You can ask, why do we need to resize menu items? Well, we need some way to show the user which bubble is “active,” and one of the best ways to do it is to enlarge that bubble. In this example, we will consider selected an item located at the top of our menu, but you can easily customize this.
Our task now looks pretty complicated. On each movement of our menu items, we need to update their sizes, based on their proximity to the menu top edge. Luckily, UIKit Dynamics has a tool for this, and it is called a Dynamic Behavior. We can create our own custom behavior, attach it to each bubble, and Dynamic Animator will call it for every bubble to compute each frame. UIKit Dynamics strives to animate at 60 FPS; thus the code should be efficient, as it will be called quite frequently. Please welcome InflateBehavior
.
import Foundation
import UIKit
class InflateBehavior : UIDynamicBehavior {
static var maxInflation: CGFloat = 1.33 //max inflation in % of the original size
static var minInflation: CGFloat = 0.66
static var distThreshold: CGFloat = 100
class func applyTransform(to view: UIView, withCenter point: CGPoint) {
let d = CGFloat(distanceBetween(p1: view.center, p2: point))
let diff: CGFloat = (InflateBehavior.maxInflation - InflateBehavior.minInflation)
let startScale = InflateBehavior.minInflation + diff/2
var scale: CGFloat = startScale + diff * (InflateBehavior.distThreshold/2 - d) / InflateBehavior.distThreshold/2
if scale > InflateBehavior.maxInflation {
scale = InflateBehavior.maxInflation
} else if scale < InflateBehavior.minInflation {
scale = InflateBehavior.minInflation
}
view.layer.transform = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
}
convenience init(view: UIView, point: CGPoint) {
self.init()
action = {InflateBehavior.applyTransform(to: view, withCenter: point)}
}
}
For UIDynamicBehavior
the main thing is to initialise action property, which should do what we want. In our case it’s pretty simple: we calculate scale, based on view center proximity to a given point, and then apply a transformation to the view layer. The math here is simple, so you can play with behavior parameters to see, how they are working.
And now, the most complicated part: dial menu itself. As I mentioned above: Dial Menu is a subclass of UIView. I’ll put it’s code here piece by piece, the full version of the code you can see on GitHub. First, we need some properties.
public var itemViews: [MenuItem] = [] {
didSet {
if itemViews.count != 0 {
setup()
}
}
}
private var snap: UISnapBehavior?
private var animator: UIDynamicAnimator?
private var centerPoints: [CGPoint] = []
private var dialCenter: CGPoint = .zero
private var snapPoint: CGPoint = .zero
The first property is itemViews
, it’s a menu item we want to see, didSet
property observer calls a function to initialize our menu. Then we have two properties to store dynamic animator instance and snap behavior. Also, we have three properties to save some geometry: center points of the menu items, the center of our dial menu view and it’s topmost point (remember, we’re using it for the currently selected item).
Now, the most math-heavy function. If you don’t remember basics of trigonometry and don’t know how sin and cos relate to a unit circle, you can use Wikipedia to refresh your memory. In short, if we have angle θ, then cos(θ)
will correspond to x
coordinate of a point on a unit circle, and sin(θ)
to an y
.
private func setup() {
animator = UIDynamicAnimator(referenceView: self)
// animator?.setValue(true, forKey: "debugEnabled")
let N = Float(self.itemViews.count)
let dialRadius = self.frame.width / 2 - (self.itemViews.first!.frame.height * InflateBehavior.maxInflation / 2)
dialCenter = CGPoint(x: self.frame.width / 2, y: self.frame.height / 2)
let coef = 2 * Float.pi/N
for (idx, item) in self.itemViews.enumerated() {
let center = CGPoint(x: dialCenter.x + dialRadius * CGFloat(sin(Float(idx) * coef)), y: dialCenter.y - dialRadius * CGFloat(cos(Float(idx) * coef)))
item.center = center
self.addSubview(item)
self.centerPoints.append(item.center)
}
snapPoint = self.centerPoints.first!
hideSubitems()
}
Here, we iterate over our menu items, calculate their centers, put them in our menu view, set center coordinates and then… we hide them. Why? Because we want a fancy animation of course! Here are two functions that will do this for us.
private func hideSubitems() {
self.itemViews.forEach {
$0.center = self.dialCenter
$0.alpha = 0
}
}
public func showSubitems() {
UIView.animate(withDuration: 0.3, animations: {
for (item, center) in zip(self.itemViews, self.centerPoints) {
item.center = center
item.alpha = 1
InflateBehavior.applyTransform(to: item, withCenter: self.snapPoint)
}
}, completion: {_ in
self.postAppearPreparations()
})
}
The logic here is pretty simple. In hideSubitems
we are moving all our menu items to the center of the menu and make them transparent, and in showSubitems
we animate them back to their positions and make them opaque. Of course, we can skip hide step and do it in the setup function, but it is always good to have the option to hide items back again, maybe you’ll want this menu to appear after some button press.
So, we animated our menu items to their positions, and here is where all the work starts. We need to keep all these elements in place and rotate them. Here is the function that will do the preparations for the trick for us. It’s two functions, but one is a simple helper.
private func attach(item fromItem: UIView, to toItem: UIView) {
let attachToPrev = UIAttachmentBehavior(item: fromItem, attachedTo: toItem)
animator?.addBehavior(attachToPrev)
}
private func postAppearPreparations() {
var prev: MenuItem?
for item in self.itemViews {
let attachToCenter = UIAttachmentBehavior(item: item, attachedToAnchor: dialCenter)
animator?.addBehavior(attachToCenter)
if let prevView = prev {
attach(item: item, to: prevView)
}
let inflate = InflateBehavior(view: item, point: self.snapPoint)
animator?.addBehavior(inflate)
prev = item
}
// attach last item to first
if let prevView = prev {
attach(item: prevView, to: self.itemViews.first!)
}
let panRecogniser = UIPanGestureRecognizer(target: self, action: #selector(onPan))
self.addGestureRecognizer(panRecogniser)
}
What’s going on here? First, we are iterating over our items, and attaching them to the center of our menu using UIAttachmentBehavior
, and also, we are connecting each element to its predecessor. Also, in this loop, we are attaching InflateBehavior
to each item. Second, we attach the last item in our list to the first item to close the circle. After this, our items will remind a bicycle wheel, each connected with neighbors and with the center. Third, and final step, we’re adding a gesture recognizer to use it for wheel rotation. By the way, if you’ll want to make this menu collapsible, you’ll need to remove this gesture recognizer in the hideSubitems
method.
Well, it’s a lot of code, but we need to add even more. Let’s add two helper functions.
private func forceRotate(to item: UIView) {
guard let animator = animator else {
return
}
if let snap = snap {
animator.removeBehavior(snap)
}
snap = UISnapBehavior(item: item, snapTo: snapPoint)
snap!.damping = 2
animator.addBehavior(snap!)
}
This one is used to force some bubble to go to the highest position. We’ll need it to make some bubble to be always in the “currently selected” point. The method is simple. First, we are removing existing snap behavior, if it is present, and then we are creating a new snap instance, binding a given view to our selected point. You can experiment with damping here. If you reduce its value, the menu will slosh in an entertaining manner for some time, giving users a seasickness.
private func closestTo(point: CGPoint) -> MenuItem {
var result = self.itemViews.first!
var dist = distanceBetween(p1: result.center, p2: point)
for item in self.itemViews {
let cur = distanceBetween(p1: item.center, p2: point)
if cur < dist {
dist = cur
result = item
}
}
return result
}
This function is easier than the previous one. It simply finds a menu item, closest to a given point. Of course, I could pretend to be a hipster-developer, and use here a reduce method to search for this closest point. But sometimes, a for loop is still good enough, and, remember, retro is always trendy.
And now, one ring to rule them… well, one method to make it moving.
internal func onPan(recognizer: UIPanGestureRecognizer) {
if recognizer.view != nil {
if recognizer.state == .began {
if let snap = snap {
animator?.removeBehavior(snap)
}
let position = recognizer.location(in: self)
let closest = self.closestTo(point: position)
snap = UISnapBehavior(item: closest, snapTo: position)
snap!.damping = 1.5
animator?.addBehavior(snap!)
} else if recognizer.state == .ended {
let closest = closestTo(point: snapPoint)
forceRotate(to: closest)
} else if recognizer.state == .changed {
snap?.snapPoint = recognizer.location(in: self)
}
}
}
When pan gesture starts, we check if there is a snap behavior from the previous time, and remove it as needed. Then, we get a bubble, closest to pan start point, and create a snap behavior for it. We’ve “grabbed” this item, and we’ll use it for rotation. Once the pan gesture recognition ends, we find an item closest to the top, and snap it to the topmost position, to ensure that we have a “selected” item.
By the way, here we should handle selected item change.
And the final step, if gesture recognizer is active, and a user is moving a finger, we just snap currently “pressed” bubble to the point, where the user’s finger is touching the screen. As we have UIAttachmentBehavior
in place, bubbles will stay in a circle, but the circle will rotate, following the user’s finger.
Rotation method provides a field for experimentation. The method I’ve used here mimics old phones dial behavior, but you can use here force with inertia, or simple translation of linear movement to angular, or many other options, depending on your needs.
Well, it’s time to brush away the sweat, make a deep breath and return to our main playground file. We’ll make it live! The code is dead simple.
import UIKit
import PlaygroundSupport
let containerView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 512, height: 512))
containerView.backgroundColor = .darkGray
let items = ["Pale lager", "Witbier", "Pilsner", "Weissbier", "APA", "IPA", "English Bitter", "Dark lager", "Porter", "Stout"].map {
MenuItem(title: $0)
}
let dialMenu = DialMenu(frame: containerView.frame)
dialMenu.itemViews = items
containerView.addSubview(dialMenu)
dialMenu.showSubitems()
PlaygroundPage.current.liveView = containerView
PlaygroundPage.current.needsIndefiniteExecution = true
Add this code, turn assistant editor on, and enjoy this beautiful and trendy menu. Of course, there is a lot of room for improvement. We can add DialMenuDataSource
protocol, make it reactive, and so on, and so forth. But it will be too much for this tutorial, and I’ll leave it to you as an exercise. Happy coding, pals!