2022/2/4更新
はじめに
SwiftUIでは、animationモディファイアやwithAnimationファンクションを使って簡単にアニメーションを実装できます。
ただ、標準のメソッドだけでは座標を動かしたり透明度を変えたりといった単純な動きしか作れない・・・と物足りなさがありました。
ところが、ほんのちょっと工夫するだけで意外にバリエーション豊かなアニメーションを作ることができることに最近気がつきました。
そこから実験的に、色々なテキストアニメーションを簡単に実装するためのライブラリを作り始めました。
まだ未整備な部分が大半ですが、GitHubにコードを公開しています。
本エントリーではその中でもお気に入りのアニメーションを簡単に作れるコツを紹介したいと思います。
Blur ブラー
String型の文字列を文字単位に切り分け、文字ごとにアニメーションの開始タイミングをずらすことで、パワーポイントのようにテキストを少しずつ表示するアニメーションを作ることができます。ここでは.blur
モディファイアと.opacity
モディファイアを組み合わせることで、霧の中からフワッと現れてくるようなアニメーションを実現しています。
struct Blur: View {
var body: some View {
VStack(alignment: .leading) {
Text("Blur Animation")
.fontWeight(.heavy)
.foregroundColor(.gray)
.padding(.bottom, 10)
VStack(alignment: .leading) {
BlurView(text: "どうしようもない", textSize: 38, startTime: 0.41)
BlurView(text: "わたしが", textSize: 38, startTime: 1.85)
BlurView(text: "歩いている", textSize: 38, startTime: 2.76)
BlurView(text: "種田山頭火", textSize: 16, startTime: 3.76)
.padding(.top, 30)
}
}
}
}
struct BlurView: View {
let characters: Array<String.Element>
let baseTime: Double
let textSize: Double
@State var blurValue: Double = 10
@State var opacity: Double = 0
init(text:String, textSize: Double, startTime: Double) {
characters = Array(text)
self.textSize = textSize
baseTime = startTime
}
var body: some View {
HStack(spacing: 1){
ForEach(0..<characters.count) { num in
Text(String(self.characters[num]))
.font(.custom("HiraMinProN-W3", fixedSize: textSize))
.blur(radius: blurValue)
.opacity(opacity)
.animation(.easeInOut.delay( Double(num) * 0.15 ), value: blurValue)
}
}
.onAppear{
DispatchQueue.main.asyncAfter(deadline: .now() + baseTime) {
if blurValue == 0{
blurValue = 10
opacity = 0.01
} else {
blurValue = 0
opacity = 1
}
}
}
}
}
Floating Text 浮遊するテキスト
`shadow`モディファイアと`offset`モディファイアを組み合わせることで、あたかも浮遊しているかのように見せています。 特に特殊なことはしていません。struct DropShadowSample: View {
var body: some View{
VStack {
DropShadow(text: "行秋や芒痩せたる影法師",
textSize: 26,
startTime: 0.1)
DropShadow(text: "寺田寅彦",
textSize: 24,
startTime: 0.1)
.padding()
}
}
}
struct DropShadow: View {
let characters: String
let baseTime: Double
let textSize: Double
@State var shadowSize: Double = 0
@State var offsetX: Double = 0
@State var offsetY: Double = 0
init(text:String, textSize: Double, startTime: Double) {
self.characters = text
self.textSize = textSize
self.baseTime = startTime
}
var body: some View {
Text(characters)
.font(.custom("HiraMinProN-W3", fixedSize: textSize))
.offset(x: offsetX, y: offsetY)
.animation(.easeInOut, value: offsetX)
.shadow(color: .black.opacity(0.8),
radius: shadowSize, x: shadowSize, y: shadowSize)
.animation(.easeInOut, value: shadowSize)
.onTapGesture {
animate(delayTime: 0.0)
}
.onAppear{
animate(delayTime: 0.2)
}
}
func animate(delayTime: Double) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01 + delayTime) {
shadowSize = 0
self.offsetX = 2
self.offsetY = 2
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3 + delayTime) {
shadowSize = 6
self.offsetX = -6
self.offsetY = -6
}
}
}
Distort In and Out ディストートイン&アウト
画面外に吸い込まれていくようにテキストが遷移するアニメーション。 `scaleEffect`と`offset`モディファイアが主役です。こちらも原理は最初のブラーと同じで、テキストを文字単位を切り分けタイミングをずらしてアニメーションしています。
サンプルでは綺麗にループするよう、テキストが透明になったタイミングでoffsetの値を変更しています。
struct DistortInOut: View {
let characters = Array("秋空を二つに断てり椎大樹")
@State var scaleSwitch: Bool = false
@State var TextOffsetX: CGFloat = 0
var body: some View {
VStack{
Text("Distort In and Out").padding()
HStack(spacing:0){
ForEach(0..<characters.count) { num in
Text(String(self.characters[num]))
.font(.custom("HiraMinProN-W3", fixedSize: 24))
.offset(x: TextOffsetX, y: 0)
.scaleEffect(x: 1, y: scaleSwitch ? 0 : 1)
.animation(.easeInOut.delay( Double(num) * 0.05 ), value: TextOffsetX)
.animation(.easeInOut.delay( Double(num) * 0.05 ), value: scaleSwitch)
}
}
.onTapGesture {
scaleSwitch.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
TextOffsetX = -500
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
TextOffsetX = 500
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
TextOffsetX = 0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
scaleSwitch.toggle()
}
}
}
}
}
}
Bounce Text 跳ねるテキスト
着地点で気持ちよくバウンドするテキストアニメーション。例によってテキストを文字単位で切り分けて動かしています。
opacity
とoffset
モディファイアと.spring
アニメーションの組み合わせがキモです。
これを応用すれば下や斜め方向から飛び出すアニメーションも作ることができます。
struct Bounce: View {
var body: some View {
VStack {
Text("Bounce Animation")
.fontWeight(.heavy)
.padding()
BounceAnimationView(text: "枯菊や日日にさめゆくいきどほり", startTime: 0.0)
BounceAnimationView(text: "萩原朔太郎", startTime: 1.5)
.padding(.top, 30)
}
}
}
struct BounceAnimationView: View {
let characters: Array<String.Element>
@State var offsetYForBounce: CGFloat = -50
@State var opacity: CGFloat = 0
@State var baseTime: Double
init(text: String, startTime: Double){
self.characters = Array(text)
self.baseTime = startTime
}
var body: some View {
HStack(spacing:0){
ForEach(0..<characters.count) { num in
Text(String(self.characters[num]))
.font(.custom("HiraMinProN-W3", fixedSize: 24))
.offset(x: 0, y: offsetYForBounce)
.opacity(opacity)
.animation(.spring(response: 0.2, dampingFraction: 0.5, blendDuration: 0.1).delay( Double(num) * 0.1 ), value: offsetYForBounce)
}
.onTapGesture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.0) {
opacity = 0
offsetYForBounce = -50
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
opacity = 1
offsetYForBounce = 0
}
}
.onAppear{
DispatchQueue.main.asyncAfter(deadline: .now() + (0.8 + baseTime)) {
opacity = 1
offsetYForBounce = 0
}
}
}
}
}
Block Revealing Animation
覆いの中からテキストが登場するアニメーション。 モーショングラフィックっぽいモダンな感じが個人的にお気に入りです。こちらはテキストではなくむしろそれを覆うRectangleが主体のアニメーションです。
仕組みとしてはおよそ下記の通り。
- Textを描写し、
GeometryReader
でサイズを取得 - 1.で取得したサイズを適用してRectangleをテキストに丁度重なるように描画
- Rectangleにscaleモディファイアを適用(scaleのプロパティは
x
に0を、anchorに左.leading
をセット) -
withAnimation
で、RectangleのX軸scaleを0から1に増加させる(覆いが右向きに登場するアニメーション) - X軸scaleが1になるタイミングでanchorを右
.trailing
に切り替えて、X軸scaleを0に減らす(覆いが右向きに退場するアニメーション)
少し長めですが、サンプルコード全体を載せます。
struct Block: View {
var body: some View {
ZStack {
ScrollView{
VStack(alignment: .leading){
BlockTextAnimation(text: "Block Reveal Animation",
font: .custom("Avenir-Black", size: 16),
startTime: 1.0)
BlockTextAnimation(text: "秋空を二つに断てり椎大樹",
font: .custom("HiraMinProN-W3", fixedSize: 27),
startTime: 1.0)
Image("sampleImage1")
.resizable()
.scaledToFit()
.padding()
.frame(height: 200)
.clipped()
BlockTextAnimation(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
font: .custom("Avenir-Light", fixedSize: 17),
startTime: 1.5)
.padding(.top, 50)
}.padding()
}
}
}
}
struct BlockTextAnimation: View {
let characters: Array<String.Element>
var font: Font
@State var rectHeight: CGFloat = 0.1
@State var pathWidth: CGFloat = 100
@State var pathHeight: CGFloat = 100
@State var rectScale: Double = 0.0
@State var rectAnchor: UnitPoint = .leading
@State var textOpacity: Double = 0.0
var baseTime: Double = 1.0
init(text: String, font: Font, startTime: Double) {
self.characters = Array(text)
self.font = font
self.baseTime = startTime
}
var body: some View {
ZStack {
Text(String(characters))
.font(font)
.opacity(textOpacity)
.background(GeometryReader{ geometry -> Text in
// NavigationLinkなどで遷移した際、
// 正しく描画後のサイズが取れないことがあるのでバッファ時間を設ける
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
rectHeight = geometry.frame(in: .local).height
pathWidth = geometry.frame(in: .local).width
pathHeight = geometry.frame(in: .local).height
}
return Text("")
})
Rectangle()
.scale(x: rectScale, y: 1, anchor: rectAnchor)
.frame(width: pathWidth, height: pathHeight, alignment: .center)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1 + baseTime) {
withAnimation(.easeInOut) {
rectScale = 1
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5 + baseTime) {
textOpacity = 1.0
rectAnchor = .trailing
withAnimation(.easeInOut){
rectScale = 0.0
}
}
}
}
.onTapGesture {
blockAnimation()
}
}
func blockAnimation(){
rectAnchor = .leading
textOpacity = 0.0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(.easeInOut) {
rectScale = 1
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
textOpacity = 1.0
rectAnchor = .trailing
withAnimation(.easeInOut){
rectScale = 0.0
}
}
}
}
Blinking with Blur ブラー×点滅のアニメーション
ジェネラティブに文字が現れるアニメーション。 これも個人的に大好きです。テキストを文字で切り分け時差を設けるところは先ほど通りで、そこにさらにランダム性を加えた結果、不思議な動きが生まれました。
ここではさらに文字の描写を袋文字→通常文字の二段構えにしています。
これによって、「文字が現れる」というだけの画面にずっと眺めていられるような魅力が生まれたような気がします。
技術的には、先ほどのBlock Revealingアニメーションよりずっとシンプルな作りになっています。
要は各文字の描写に時差を設けて、袋文字に通常文字を重ねただけです。
コードを下に掲載します。
struct BlinkingWithBlurSample: View {
let textSize: Double = 30
var body: some View {
VStack{
BlinkingWithBlur(text: "Demain, dès l’aube,",
fontName: "Didot",
textSize: textSize,
startTime: 0.1)
BlinkingWithBlur(text: "à l'heure",
fontName: "Didot",
textSize: textSize,
startTime: 0.3)
BlinkingWithBlur(text: "où blanchit la campagne",
fontName: "Didot",
textSize: textSize,
startTime: 0.5)
BlinkingWithBlur(text: "Je partirai. Vois-tu,",
fontName: "Didot",
textSize: textSize,
startTime: 0.7)
BlinkingWithBlur(text: "je sais que tu m'attends.",
fontName: "Didot",
textSize: textSize,
startTime: 0.9)
BlinkingWithBlur(text: "Victor Hugo",
fontName: "Didot",
textSize: 24,
startTime: 2.5)
.padding(.top,100)
}
}
}
struct BlinkingWithBlur: View {
let characters: Array<String.Element>
let fontName: String
let baseTime: Double
let textSize: Double
@State var blurValue: Double = 10
@State var opacity: Double = 0
init(text:String, fontName: String, textSize: Double, startTime: Double) {
self.characters = Array(text)
self.fontName = fontName
self.textSize = textSize
self.baseTime = startTime
}
var body: some View {
HStack(spacing: 0.5){
ForEach(0..<characters.count) { num in
ZStack{
Text(String(self.characters[num]))
.font(.custom(fontName, fixedSize: textSize * 1.0))
.blur(radius: blurValue)
.opacity(opacity)
.animation(.easeInOut.delay( baseTime + Double(num) * 0.5 * Double.random(in: 0.003...0.55)),
value: blurValue)
Text(String(self.characters[num]))
.font(.custom(fontName, fixedSize: textSize * 0.9))
.blur(radius: blurValue)
.foregroundColor(.white)
.opacity(opacity)
.animation(.easeInOut.delay( baseTime + Double(num) * 0.5 * Double.random(in: 0.003...0.55)),
value: blurValue)
Text(String(self.characters[num]))
.font(.custom(fontName, fixedSize: textSize*1.0))
.blur(radius: blurValue)
.opacity(opacity)
.animation(.easeInOut.delay( baseTime + Double(num) * 1 * Double.random(in: 0.10...0.3)),
value: blurValue)
}
}
}
.onAppear{
DispatchQueue.main.asyncAfter(deadline: .now() + baseTime) {
if blurValue == 0{
blurValue = 10
opacity = 0.01
} else {
blurValue = 0
opacity = 1
}
}
}
}
}
Roulette ルーレットテキスト
文字がスピーディーに入れ替わりながら現れてくるアニメーションです。
引数として与えた文字列を配列化し、その配列からランダムに文字を取り出し表示することでSFチックな演出を作り出しています。
技術的にはTimerを使って一定時間ループさせている点が他と異なる特徴です。
こちらも少し長いですがサンプルコードを掲載します。
struct RouletteText: View {
var body: some View {
VStack(alignment:.leading){
RouletteCharacters(text: "To be or", delay: 0.3)
RouletteCharacters(text: "not to be,", delay: 1.3)
RouletteCharacters(text: "that is the", delay: 1.8)
RouletteCharacters(text: "question.", delay: 2.3)
}
.padding()
}
}
struct RouletteCharacters: View {
let characters: Array<String.Element>
let fontSize: Float = 78
var delay: Double
init(text: String, delay: Double){
self.characters = Array(text)
self.delay = delay
}
var body: some View {
VStack(alignment: .leading) {
HStack(spacing:0){
ForEach(0..<characters.count) { num in
RouletteCharacter(characters: String(characters),
finalCharacter: String(characters[num]),
number: num,
fontSize: fontSize,
baseTime: delay)
}
Spacer()
}
}
}
}
struct RouletteCharacter: View {
@State var characters: Array<String.Element>
@State var finalCharacter: String
@State var separatedCharacter: String
@State var timer: Timer!
@State var number: Int
@State var fontSize: Float
@State var baseTime: Double
@State var opacity: Double = 0
var speedDuration: Double = 0.2
init(characters: String, finalCharacter: String, number: Int, fontSize: Float, baseTime: Double){
self.characters = Array(characters)
self.finalCharacter = finalCharacter
self.separatedCharacter = finalCharacter
self.number = number
self.fontSize = fontSize
self.baseTime = baseTime
}
var body: some View {
Text(String(separatedCharacter))
.font(.custom("Arial", size: CGFloat(fontSize)))
.fontWeight(.heavy)
.foregroundColor(Color(CGColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1)))
.opacity(opacity)
.onAppear{
DispatchQueue.main.asyncAfter(deadline: .now() + (baseTime / 4) + speedDuration * Double(1 + number)){
withAnimation(.linear(duration: 0.2)) {
opacity = 1
}
}
startTimer()
DispatchQueue.main.asyncAfter(deadline: .now() + (baseTime + speedDuration * Double(1 + number))) {
stopTimer()
separatedCharacter = finalCharacter
}
}
}
func startTimer() {
self.timer = Timer.scheduledTimer(withTimeInterval: 0.05,
repeats: true, block: { _ in
// shuffle and set characters.
separatedCharacter = String(characters[Int.random(in: 0..<characters.count)])
})
}
func stopTimer() {
self.timer?.invalidate()
self.timer = nil
}
}
最後に
SwiftUI Text Animation Libraryのレシピは今後も追加予定です。
新しく良い感じのアニメーションができたら本エントリーも随時更新していきたいと思ってます。
ここまでご覧いただきありがとうございます。