This article is the ** 24th day ** article of and factory Advent Calendar 2020. Yesterday was @yst_i's "I implemented an animation using MotionLayout's KeyCycle"!
This time, it was written by @ fr0g_fr0g. "I made an iOS app for MVP + Clean Architecture using Poké API, so I will explain it" It is an article that I tried to introduce UI animation to the application called Pokedex explained in.
The development background of Pokedex is as follows.
Recently, when talking to people who have no experience in iOS work at study sessions, I was conscious of what I should learn to get a job as an iOS engineer and what I actually do in my work. I'm often asked if I'm making full use of design and tools, so I wish I could explain it.
When I saw this In my case, I've had a lot of opportunities to talk about UI animation in my career (both web front and iOS, but both), so there are quite a few people who are interested in that. I have felt that, so By adding that element to Pokedex, I thought it would be a product that more beginners would learn more.
So, this time I'm talking about introducing UI animation to Pokedex.
In my opinion, both internal quality (design) and external quality (appearance) are important for a product. No matter how good the internal quality is, if the external quality is not enough, ** it may feel "difficult to use" or "poor" or you may not be attached to it **. No matter how good the external quality is, if the internal quality is not enough, ** it will take time to generate defects, repair and implement additional functions, and it will be difficult to deliver value. ** ** Therefore, by aiming for height in both cases, the absolute value of the product will increase, and it will become a ** "used" ** product for users. This absolute value will be an important factor, especially if there are products with similar themes out there, in order to win the user's ** "which one to use" choice **.
As an engineer It ’s good to have both internal and external quality, It is good to specialize in internal quality (external quality), I think both of them will be valuable human resources in team development.
Therefore, this time, in order to improve the external quality of Pokedex, which has solid internal quality, we added UI animation. It is a project to improve the value of Pokedex so that everyone who is interested in the inside and those who are interested in the outside can learn **. (Projected without permission)
This is the screen.
It is a screen to see a list of Pokemon. Here we do the following:
So, I will explain what each of them thought about and animated.
The aim is to create fun as a Pokédex by scrolling. Currently, we do not have a method to find a specific Pokemon such as sorting, narrowing down, free word search, etc., so scrolling tends to increase when using this screen, so it is likely to be more effective. Shin. Therefore, this time, we have incorporated an animation ** in which the UI flows from right to left when Pokemon appears so that Pokemon will appear one after another according to scrolling. The animation of each element is quite simple because it only flows in, but since multiple elements are displayed on one screen, the screen as a whole makes a comfortable movement. ** If you are too elaborate on each animation, your eyes will get tired when multiple animations appear **, so while keeping it moderate ** Adjust so that even if multiple elements move, they will not be isolated and the comfort will overlap ** I will continue to do it. Specifically, be aware of ** shifting the start timing for each element ** by starting the animation at the timing when the elements are displayed by scrolling, and ** making the animation time and easing familiar **. Did. It is important to see both the trees and the forest, as the balance between the individual elements and the entire screen affects the usability.
If you want to see the details of the target Pokemon and tap the element, it will transition to the details screen. Well, this is what the user expected. The aim is to add another twist to this and make Pokemon more attached. It's more fun to think that Pokemon are alive! (What happened suddenly) So, when the user selects Pokemon, I wanted to make the Pokemon respond. (It's the one you decided on, yeah) I don't know if it's two-way communication, but it's more fun to be able to imagine that. Hopefully, I wish I could make a cry when I selected it, but the difference between games and apps also appears in these places, so it's not used because of the sound. I'm surprised to hear the sound without permission, stop. I thought that it was not suitable for this app because it is an environment that has up to, so this time I decided to express the answer by jumping Pokemon. Specifically, I settled down to the point that ** by jumping when pressed (when pushed), you can check the jump while making a transition **. If you jump when tapped (when pressed and released), it will transition before you see the jump, so it's a bit sad, but delaying the transition to show the jump will block the user's action, so refrain from doing so Without it, I settled down here. In the app, I think it's okay to make the animation at the destination reasonably flashy, but the important point is not to disturb the user until you get there.
I will put the code concretely.
First, the Cell file.
--Move from the position before animation to the target position --Bounce Pokemon when pressed
PokemonListCell.swift
final class PokemonListCell: UITableViewCell {
func abbreviate() {
let x: CGFloat = UIScreen.main.bounds.width * 0.375 //How much is the initial position
self.innerView.transform = .init(translationX: x, y: 0.0)
self.innerView.alpha = 0.3
}
func expand() {
self.innerView.transform = .identity
self.innerView.alpha = 1.0
}
func animateImage() {
let keyframeTranslateY = CAKeyframeAnimation(keyPath: "transform.translation.y") //keyPath does not work unless you enter a fixed character string
keyframeTranslateY.values = [0.0, -5.0, 0,0, -2.5, 0.0]
keyframeTranslateY.keyTimes = [0, 0.25, 0.4, 0.6, 1.0]
keyframeTranslateY.duration = 0.2
self.spriteImageView.layer.add(keyframeTranslateY, forKey: "jumping") //This forKey string is free
}
}
And the view controller. It is a lot of excerpts. Since it appears according to scrolling, the Cell animation is run with willDisplay of UITableView.
PokemonListViewController.swift
final class PokemonListViewController: UIViewController {}
// MARK: - UITableViewDelegate
extension PokemonListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let cell = cell as? PokemonListCell else { return }
cell.abbreviate() //Move to the initial position
UIView.animate(withDuration: 0.4, delay: 0, options: [.allowUserInteraction, .curveEaseInOut], animations: {
cell.expand() //Start animation
}, completion: nil)
}
}
This is the screen.
This is the screen to see the detailed information of Pokemon. Here we do the following:
This time, I will explain the animation related to "View detailed information of the target Pokemon". (Those before evolution will be omitted this time)
See detailed information Since the target Pokemon has been decided and changed, we will focus on "charming" Pokemon. In words, the UI animation was designed by focusing on the part of ** animation to make people attach to it ** rather than the part of displaying information without waiting for the user. It takes more time than displaying it at once, but since this is one destination without doing anything from the Pokemon details screen, I thought it was important to increase the enjoyment of seeing Pokemon. On the other hand, excessive production can cause dissatisfaction, saying, "It takes time to see the details of Pokemon," so a sense of balance is important. If you divide the main important parts into phases
There are three. I would like to write about each of them.
From the screen transition to getting the detailed information of Pokemon with API, isn't it? If it is a general application, I think that it will display an indicator etc. to inform the user that data is being loaded. There is no difference with Pokedex, but I wanted to make it a loading display related to Pokemon instead of just an indicator, so this time I decided to shake the monster ball to create a ** Pokemon feeling that it will come out soon **. .. At the same time, I also wanted to make it so that it could be connected to the next Pokemon display phase. If there are multiple phases, ** continuous animation will make the flow feel more comfortable as a whole, rather than separate animations. ** **
How to get data and display Pokemon. I was shaking the monster ball while acquiring the data earlier, so I wanted to make it look like it came out of it, so I saw that scene many times in Pokemon's animation. Apparently, there seems to be a phase in which the monster ball opens and the Pokemon comes out, while something strange or hazy comes out of the monster ball. Then, I wanted to incorporate it based on that as much as possible. For those who like the original, it is better to reproduce it as much as possible or make it an animation that makes you feel it, as long as the original one already exists in the world. So, since the theme of this time is Pokemon, which has a lot of fans, I thought that I could not remove it, and it is no exaggeration to say that I designed the entire animation in that direction. Specifically, after the monster ball shook and a haze appeared there, the monster that had shrunk to the size of the monster ball appeared while scaling to the real size. So, I think that this is the most interesting point throughout, so I decided to execute the animation in the center of the screen so that I can focus only on the monsters without displaying any other information. This is also one point, it is easy to hide other elements to draw attention to something, but that alone does not explain the ** hidden margin * * May be. In this case, the Pokemon is placed at the top as the final state of the screen, so if you hide the part below the name, about half of the screen will be wrapped in a strange margin. Then, what is this margin? Therefore, it is important to be aware of such ** feelings that you do not want to embrace and psychology that you do not want to work, and focus on what you want to show **.
It is a scene to display the status of Pokemon after displaying the appearance of Pokemon. Since the Pokemon was displayed in the center of the screen in the previous phase, it is necessary to move it to the final state position of the screen from there. At the same time, it is necessary to display the status, so it is comfortable to ** shift the line of sight to it as smoothly as possible **. If you feel a break, you will feel uncomfortable, so be careful not to do so. Since the status part has a UI similar to the list screen, the movement should match it. As the overall design of the app, ** similar UIs will give a sense of unity by performing similar movements and functions, and users will be able to use them without hesitation **. The status is divided into two parts, the left side that displays the type and ability, and the right side that displays the parameters such as attack and defense, and the right side contains another animation of increasing values and bar graphs. .. Attack and defense parameters are assigned to each Pokemon, and the higher the value, the stronger the **. For values and graphs of these properties, animation that extends from 0 to the desired value makes it easier to understand because you can intuitively feel the size and amount.
For those that originally have originals such as anime and games, I would like to do something about it because it will be a source of quality and attachment. However, I used ** SpriteKit ** this time because that movement cannot be dealt with by simple deformation.
I will put the code concretely.
As a point, in order to secure the minimum animation time of the monster ball, the appearance animation is executed with the timer of the number of seconds and the loading of the image as a trigger. I don't know what happened if the monster ball is displayed for a moment, so I have some time to create the feeling of coming out of the monster ball.
Animation is roughly divided into three.
Let's look at each.
Animation of Pokemon images is done by ** CA Basic Animation **. He is good at simple animation that moves from this value to this value in this time. This time Reduce the transparency and scale before it appears, By increasing the transparency and scale at the time of appearance and adding a movement animation in the middle It expresses the feeling of being three-dimensionally coming out of the monster ball.
Also, I want to express the feeling that comes out of the monster ball, but when I try to animate the element by default, the center of the element becomes the base point. If that is the case, the feeling of coming out of the monster ball cannot be expressed, so
self.imageView.layer.anchorPoint = .init(x: 0.5, y: 1.0)
As a starting point, the bottom center of the element.
PokemonDetailImageView.swift
final class PokemonDetailImageView: XibLoadableView {
@IBOutlet private weak var imageView: UIImageView! {
willSet {
newValue.layer.anchorPoint = .init(x: 0.5, y: 1.0)
newValue.alpha = 0.0
}
}
private func animate() {
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
guard let self = self else { return }
self.appearImage()
})
}
private func appearImage() {
let opacityAnimate = CABasicAnimation(keyPath: "opacity")
opacityAnimate.fromValue = 0.0
opacityAnimate.toValue = 1.0
opacityAnimate.duration = 0.2
opacityAnimate.timingFunction = Easing.EaseOut.quart.function
opacityAnimate.isRemovedOnCompletion = false
opacityAnimate.fillMode = .forwards
let scaleAnimate = CABasicAnimation(keyPath: "transform.scale")
scaleAnimate.fromValue = 0.2
scaleAnimate.toValue = 1.0
scaleAnimate.duration = 0.2
scaleAnimate.timingFunction = Easing.EaseOut.quart.function
scaleAnimate.isRemovedOnCompletion = false
scaleAnimate.fillMode = .forwards
let startYAnimate = CABasicAnimation(keyPath: "transform.translation.y")
startYAnimate.fromValue = 0.0
startYAnimate.toValue = -20.0
startYAnimate.duration = 0.15
startYAnimate.timingFunction = Easing.EaseInOut.circ.function
startYAnimate.isRemovedOnCompletion = false
startYAnimate.fillMode = .forwards
let endYAnimate = CABasicAnimation(keyPath: "transform.translation.y")
endYAnimate.fromValue = -20.0
endYAnimate.toValue = 0.0
endYAnimate.duration = 0.2
endYAnimate.beginTime = CACurrentMediaTime() + 0.15
endYAnimate.timingFunction = Easing.EaseInOut.circ.function
endYAnimate.isRemovedOnCompletion = false
endYAnimate.fillMode = .forwards
endYAnimate.delegate = self
self.imageView.layer.add(opacityAnimate, forKey: "opacity")
self.imageView.layer.add(scaleAnimate, forKey: "scale")
self.imageView.layer.add(startYAnimate, forKey: "translation.y.start")
self.imageView.layer.add(endYAnimate, forKey: "translation.y.end")
}
}
Animation of monster ball is done in ** CA Keyframe Animation **. It is repeatedly executed with keyframe animation. At this timing, you can finely set what you want to be in this state, and you are good at repeating the same movement.
By specifying values and keyTimes, you can set which state at what timing. By setting repeatDuration to .infinity, it will be infinitely repeated.
Also, when rotating as standard, it also tries to rotate around the center point of the element. However, in order to express that the monster ball sways, I want you to rotate around the bottom center, so set the anchor point
self.monsterBallImageView.layer.anchorPoint = .init(x: 0.5, y: 1.0)
It is said.
I'm going to get data with viewDidLoad, but in the meantime, in order to shake the monster ball, I shake the monster ball with viewDidAppear. (Hit prepareLoading with viewDidAppear)
PokemonDetailImageView.swift
final class PokemonDetailImageView: XibLoadableView {
@IBOutlet private weak var monsterBallImageView: UIImageView!
func prepareLoading() {
self.showMonsterBall()
}
private func animate() {
self.hideMonsterBall()
}
}
// MARK: - MonsterBall
extension PokemonDetailImageView {
private func showMonsterBall() {
let keyframeRotate = CAKeyframeAnimation(keyPath: "transform.rotation.z")
keyframeRotate.values = [0, 20 * CGFloat.pi / 180, 0, -20 * CGFloat.pi / 180, 0]
keyframeRotate.keyTimes = [0, 0.25, 0.5, 0.75, 1]
keyframeRotate.duration = 1.2
keyframeRotate.repeatDuration = .infinity
let position = self.monsterBallImageView.layer.position
self.monsterBallImageView.layer.anchorPoint = .init(x: 0.5, y: 1.0)
self.monsterBallImageView.layer.position = .init(x: position.x, y: position.y + self.monsterBallImageView.bounds.height / 2)
self.monsterBallImageView.layer.add(keyframeRotate, forKey: "transform.rotation.z")
UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseOut, animations: { [weak self] in
guard let self = self else { return }
self.monsterBallImageView.alpha = 1.0
}, completion: nil)
}
private func hideMonsterBall() {
UIView.animate(withDuration: 0.2, delay: 0.2, options: .curveEaseOut, animations: { [weak self] in
guard let self = self else { return }
self.monsterBallImageView.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
self.monsterBallImageView.alpha = 0.0
}, completion: nil)
}
}
Animation of white moyamoya is done with ** SpriteKit **.
(Here, explanation of particle creation)
So, in order to move the created file, prepare SKView and specify SKScene as present. This is an image of the particles moving in the place called SKScene specified by the element called SKView.
When you're ready, create a SKEmitterNode based on the particle file you just created. After that, put it on the scene and the animation will start!
By combining these three animations We were able to realize a Pokemon appearance animation.
PokemonDetailImageView.swift
final class PokemonDetailImageView: XibLoadableView {
private var skView: SKView?
private func animate() {
self.createEmitter()
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
guard let self = self else { return }
self.hideEmitter()
})
}
}
// MARK: - Emitter
extension PokemonDetailImageView {
private func createEmitter() {
let size = self.bounds
let doubleSize = CGSize(width: size.width * 2, height: size.height * 2)
//Prepare the view itself in double size so that the particles do not break
self.skView = SKView(frame: .init(origin: .init(x: -self.bounds.width / 2, y: -self.bounds.height / 2), size: doubleSize))
self.skView?.backgroundColor = .clear
self.addSubview(self.skView!)
let scene = SKScene(size: self.skView?.bounds.size ?? .zero)
scene.backgroundColor = .clear
self.skView?.presentScene(scene)
if let node = SKEmitterNode(fileNamed: "appearPokemon") {
node.position = CGPoint(x: scene.frame.width / 2, y: scene.frame.height / 2 - self.bounds.height / 2 + 60)
scene.addChild(node)
}
}
private func hideEmitter() {
UIView.animate(withDuration: 0.1, delay: 0.2, options: .curveEaseOut, animations: { [weak self] in
guard let self = self else { return }
self.skView?.subviews.forEach { $0.removeFromSuperview() }
self.skView?.removeFromSuperview()
self.skView = nil
}, completion: nil)
}
}
Finally the whole code.
PokemonDetailImageView.swift
import UIKit
import SpriteKit
protocol PokemonDetailImageViewDelegate: AnyObject {
func finishedPokemonImageViewShowAnimation()
}
final class PokemonDetailImageView: XibLoadableView {
@IBOutlet private weak var imageView: UIImageView! {
willSet {
newValue.layer.anchorPoint = .init(x: 0.5, y: 1.0)
newValue.alpha = 0.0
}
}
@IBOutlet private weak var monsterBallImageView: UIImageView!
private var skView: SKView?
private var isLoading: Bool = false
weak var delegate: PokemonDetailImageViewDelegate?
func prepareLoading() {
self.showMonsterBall()
}
func setImage(_ imageUrl: URL?) {
self.isLoading = false
Timer.scheduledTimer(withTimeInterval: 0.8, repeats: false, block: { [weak self] _ in
guard let self = self else { return }
self.animate()
})
self.imageView.loadImage(with: imageUrl, placeholder: nil, completion: { [weak self] _ in
guard let self = self else { return }
self.animate()
})
}
private func animate() {
//Delay and image loading for monster ball animation, start Pokemon appearance animation triggered by the slower one
if !self.isLoading {
self.isLoading = true
return
}
self.hideMonsterBall()
self.createEmitter()
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
guard let self = self else { return }
self.hideEmitter()
self.appearImage()
})
}
private func appearImage() {
let opacityAnimate = CABasicAnimation(keyPath: "opacity")
opacityAnimate.fromValue = 0.0
opacityAnimate.toValue = 1.0
opacityAnimate.duration = 0.2
opacityAnimate.timingFunction = Easing.EaseOut.quart.function
opacityAnimate.isRemovedOnCompletion = false
opacityAnimate.fillMode = .forwards
let scaleAnimate = CABasicAnimation(keyPath: "transform.scale")
scaleAnimate.fromValue = 0.2
scaleAnimate.toValue = 1.0
scaleAnimate.duration = 0.2
scaleAnimate.timingFunction = Easing.EaseOut.quart.function
scaleAnimate.isRemovedOnCompletion = false
scaleAnimate.fillMode = .forwards
let startYAnimate = CABasicAnimation(keyPath: "transform.translation.y")
startYAnimate.fromValue = 0.0
startYAnimate.toValue = -20.0
startYAnimate.duration = 0.15
startYAnimate.timingFunction = Easing.EaseInOut.circ.function
startYAnimate.isRemovedOnCompletion = false
startYAnimate.fillMode = .forwards
let endYAnimate = CABasicAnimation(keyPath: "transform.translation.y")
endYAnimate.fromValue = -20.0
endYAnimate.toValue = 0.0
endYAnimate.duration = 0.2
endYAnimate.beginTime = CACurrentMediaTime() + 0.15
endYAnimate.timingFunction = Easing.EaseInOut.circ.function
endYAnimate.isRemovedOnCompletion = false
endYAnimate.fillMode = .forwards
endYAnimate.delegate = self
self.imageView.layer.add(opacityAnimate, forKey: "opacity")
self.imageView.layer.add(scaleAnimate, forKey: "scale")
self.imageView.layer.add(startYAnimate, forKey: "translation.y.start")
self.imageView.layer.add(endYAnimate, forKey: "translation.y.end")
}
}
// MARK: - MonsterBall
extension PokemonDetailImageView {
private func showMonsterBall() {
let keyframeRotate = CAKeyframeAnimation(keyPath: "transform.rotation.z")
keyframeRotate.values = [0, 20 * CGFloat.pi / 180, 0, -20 * CGFloat.pi / 180, 0]
keyframeRotate.keyTimes = [0, 0.25, 0.5, 0.75, 1]
keyframeRotate.duration = 1.2
keyframeRotate.repeatDuration = .infinity
let position = self.monsterBallImageView.layer.position
self.monsterBallImageView.layer.anchorPoint = .init(x: 0.5, y: 1.0)
self.monsterBallImageView.layer.position = .init(x: position.x, y: position.y + self.monsterBallImageView.bounds.height / 2)
self.monsterBallImageView.layer.add(keyframeRotate, forKey: "transform.rotation.z")
UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseOut, animations: { [weak self] in
guard let self = self else { return }
self.monsterBallImageView.alpha = 1.0
}, completion: nil)
}
private func hideMonsterBall() {
UIView.animate(withDuration: 0.2, delay: 0.2, options: .curveEaseOut, animations: { [weak self] in
guard let self = self else { return }
self.monsterBallImageView.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
self.monsterBallImageView.alpha = 0.0
}, completion: nil)
}
}
// MARK: - Emitter
extension PokemonDetailImageView {
private func createEmitter() {
let size = self.bounds
let doubleSize = CGSize(width: size.width * 2, height: size.height * 2)
//Prepare the view itself in double size so that the particles do not break
self.skView = SKView(frame: .init(origin: .init(x: -self.bounds.width / 2, y: -self.bounds.height / 2), size: doubleSize))
self.skView?.backgroundColor = .clear
self.addSubview(self.skView!)
let scene = SKScene(size: self.skView?.bounds.size ?? .zero)
scene.backgroundColor = .clear
self.skView?.presentScene(scene)
if let node = SKEmitterNode(fileNamed: "appearPokemon") {
node.position = CGPoint(x: scene.frame.width / 2, y: scene.frame.height / 2 - self.bounds.height / 2 + 60)
scene.addChild(node)
}
}
private func hideEmitter() {
UIView.animate(withDuration: 0.1, delay: 0.2, options: .curveEaseOut, animations: { [weak self] in
guard let self = self else { return }
self.skView?.subviews.forEach { $0.removeFromSuperview() }
self.skView?.removeFromSuperview()
self.skView = nil
}, completion: nil)
}
}
extension PokemonDetailImageView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
self.delegate?.finishedPokemonImageViewShowAnimation()
}
}
People are good and bad, some are good at design and some are good at animation. There is no right answer, and it is worthwhile for those who are overwhelmingly strong in one of them and those who can do it all evenly. That's why I hope that this Pokedex for beginners will be a project that can meet various needs, so I implemented animation this time. To do this kind of animation, you need this kind of technology. And There are various implementation methods for one animation. I hope it will be helpful for you. What if, The fun of Pokemon appearing! I'm glad if you are interested in animation from here. I would like to conclude with the hope that it will lead to an increase in the value of products in the world and an improvement in the quality of life of users.
Recommended Posts