🐷

UISearchBarのプレースホルダーをアニメーションさせる

2020/10/08に公開

完成品

https://github.com/wecken/UISearchBarPlaceholderSwitchingEffect

検索窓のプレースホルダーを、予め設定したキーワードを表示して、順番にローテーションするUIです。

何が嬉しいのか

検索窓のプレースホルダーは具体的な言葉の方が「どういう言葉を検索すればいいのか分かる」というメリットがあります。
その一方で、具体的すぎると他のキーワードがイメージしづらくなって、ざっくりとした表現のプレースホルダーを入れがち。

「ラーメン店の名前を入力」って書いてるより、「一風堂」って書いてる方が具体的に検索ワードが思いつきやすい。けど、一風堂だけだと流石に違和感があるので「一蘭」とか「天下一品」も表示したいよね、っていうことです。

技術

UIKitの標準的なアニメーションで(view.animate)は、今回のようなplaceholderの透明度など一部のパラメータをアニメーションできないという問題があります。
他にも角丸を徐々に変更するとかが無理っぽい。
そういうときはCoreAnimation系のAPIを使うことになるのですが、今回使用しているのはCADisplayLink
CADisplayLinkはコードの実行を画面のリフレッシュレート(基本60fps)に同期してくれるタイマーみたいなAPIです。
タイマーといえばNSTimerというものもあって、これでアニメーションを実装しているコードなども見かけるのですが、NSTimerは60fpsを保証してくれるわけではないらしく、CoreAnimationのAPIであるDisplayLinkの方がアニメーション向けには良いらしい。

前述の通り、あくまでタイマー的な機能でしかないので、アニメーションの管理は自分でやらないといけません。

コード

//
//  ViewController.swift
//  UILabelAnimation
//
//  Created by Ukkun

import UIKit

class ViewController: UIViewController {

    let searchBox = UISearchBar()
    
    var placeholders = ["MacBook Air", "MacBook Pro","AirPods","AirPods Pro","iPad Pro",]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(searchBox)
        searchBox.frame = view.frame
        
        let displayLink = CADisplayLink(target: self, selector: #selector(handleUpdate))
        displayLink.add(to: .main, forMode: .default)
        
        self.placeholders = self.placeholders.shuffled()
       
    }
    
    let keyframes:[Double] = [0.0, 0.4, 3.4, 3.8]
    var animationStartDate = Date()
    var placeHolderOpcity:Double = 1

    var currentPlaceholder = 0


    func setPlaceholderTextAndOpacity(opacity:Double, text:String) {
        searchBox.searchTextField.attributedPlaceholder = NSAttributedString( string: text, attributes: [NSAttributedString.Key.foregroundColor : UIColor.init(red: 0.576, green: 0.569, blue: 0.561, alpha: CGFloat(opacity))])
    }

    @objc func handleUpdate() {

        let now = Date()
        let elapsedTime = now.timeIntervalSince(animationStartDate)

        switch elapsedTime {
            case 0 ..< keyframes[1]:
                //keyframe 0

                let animationDuration = keyframes[1] - keyframes[0]
                let percentage:Double = elapsedTime / animationDuration
                placeHolderOpcity = percentage
                setPlaceholderTextAndOpacity(opacity: placeHolderOpcity, text: placeholders[currentPlaceholder])

            case keyframes[1] ..< keyframes[2]:
                //keyframe 1
                placeHolderOpcity = 1
                
                setPlaceholderTextAndOpacity(opacity: placeHolderOpcity, text: placeholders[currentPlaceholder])

            
            case keyframes[2] ..< keyframes[3]:
                
                //keyframe 2
                let elapsedTimeInKeyframe = elapsedTime - keyframes[2]
                let animationDuration = keyframes[3] - keyframes[2]
                let percentage:Double = elapsedTimeInKeyframe / animationDuration
                placeHolderOpcity = 1 - percentage
                setPlaceholderTextAndOpacity(opacity: placeHolderOpcity, text: placeholders[currentPlaceholder])

            default:
                animationStartDate = Date()

                if currentPlaceholder == placeholders.count - 1 {
                    currentPlaceholder = 0
                } else {
                    currentPlaceholder += 1
                }
                setPlaceholderTextAndOpacity(opacity: 0, text: placeholders[currentPlaceholder])
        }
    }
}


解説

DisplayLinkの設置

let displayLink = CADisplayLink(target: self, selector: #selector(handleUpdate)) // 1フレームごとに、handleUpdate関数を実行する。
displayLink.add(to: .main, forMode: .default)

キーフレームの設定。
各種初期化。

let keyframes:[Double] = [0.0, 0.4, 3.4, 3.8] //キーフレームの設定。アニメーションが発生して、0.0秒後がkeyframe 0で、0.4秒がkeyframe 1という具合。
var animationStartDate = Date() //DisplayLinkのタイマー用。アニメーション開始時の時刻を記録して、どれだけ時間が経ったかカウントする。
var placeHolderOpcity:Double = 1 // placeholderのOpacityをアニメーションするので、初期値を1にしておく。

var currentPlaceholder = 0 // どのPlaceholderを出すかの初期化。

placeholderの透明度とテキストを変更する独自関数。

// placeholderの不透明度を設定するコードが長すぎるので、関数化。不透明度と、プレースホルダーにするテキストを受け取る
func setPlaceholderTextAndOpacity(opacity:Double, text:String) {
	searchBox.searchTextField.attributedPlaceholder = NSAttributedString( string: text, attributes: [NSAttributedString.Key.foregroundColor : UIColor.init(red: 0.576, green: 0.569, blue: 0.561, alpha: CGFloat(opacity))])
}

60fpsで実行されるコード部分。

@objc func handleUpdate() {
	///この中が1フレーム(1/60秒)ごとに実行される
}

アニメーションの準備。

let now = Date() // 経過時間を測るための、「今何時?」を決める
let elapsedTime = now.timeIntervalSince(animationStartDate) // アニメーション開始から「今」までの経過時間

アニメーションの中身。

switch elapsedTime {
    case 0 ..< keyframes[1]: // keyframe 0 から 1 までの間はここを実行
	let animationDuration = keyframes[1] - keyframes[0] // アニメーションのかかる時間=Durationを計算
	let percentage:Double = elapsedTime / animationDuration // アニメーションが全体のうち何%進んだかを計算。
	placeHolderOpcity = percentage // プレースホルダーの不透明度はアニメーションの進行度合いと一致させる。つまり、徐々に不透明になる。
	setPlaceholderTextAndOpacity(opacity: placeHolderOpcity, text: placeholders[currentPlaceholder]) // プレースホルダーの透明度を設定。

    case keyframes[1] ..< keyframes[2]:
	//keyframe 1 から 2まではここを実行
	placeHolderOpcity = 1 // プレースホルダーの不透明度はずっと1。つまりずっと見えたまま。

	setPlaceholderTextAndOpacity(opacity: placeHolderOpcity, text: placeholders[currentPlaceholder])


    case keyframes[2] ..< keyframes[3]:

	//keyframe 2から3までの間はここを実行。
	let elapsedTimeInKeyframe = elapsedTime - keyframes[2] // Keyframe0と同様にpercentageを計算するために、このキーフレーム内での経過時間を計算する必要がある
	let animationDuration = keyframes[3] - keyframes[2] // アニメーションにかかる時間を計算。
	let percentage:Double = elapsedTimeInKeyframe / animationDuration // アニメーションの進行度合いを計算
	placeHolderOpcity = 1 - percentage // プレースホルダーの不透明度はアニメーションの進行度合いに伴って、0に近く。つまり徐々に透明になる。
	setPlaceholderTextAndOpacity(opacity: placeHolderOpcity, text: placeholders[currentPlaceholder])

    default: // 最後Keyframeを超えるとここが実行される
	animationStartDate = Date() //アニメーションの開始時刻を初期状態にリセット。

	if currentPlaceholder == placeholders.count - 1 { // 次のプレースホルダーに。
	    currentPlaceholder = 0
	} else {
	    currentPlaceholder += 1
	}
	setPlaceholderTextAndOpacity(opacity: 0, text: placeholders[currentPlaceholder])
}

Discussion