Search code examples
iosswiftuikit

How to achieve this kind of animation with the placeholder text in UIKit?


In this gif, the placeholder text keeps changing dynamically, one after another

This is the code that I am using to acieve this:

import UIKit

class ViewController: UIViewController, UISearchBarDelegate {
    
    var placeholderIndex = 0
    var placeholderTexts = ["Text 1", "Text 2", "Text 3"]
    var searchBar: UISearchBar!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        searchBar = UISearchBar()
        searchBar.placeholder = "Search...." // Set initial placeholder
        searchBar.searchBarStyle = .prominent
        searchBar.sizeToFit()
        searchBar.isTranslucent = true
        searchBar.delegate = self
        searchBar.backgroundImage = UIImage()
        searchBar.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(searchBar)
        
        Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(updatePlaceholder), userInfo: nil, repeats: true)
        
        NSLayoutConstraint.activate([
            searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            searchBar.heightAnchor.constraint(equalToConstant: 70)
        ])
    }
    
    
    @objc func updatePlaceholder() {
        let nextPlaceholderLabel = UILabel(frame: CGRect(x: 0, y: -searchBar.frame.height, width: searchBar.frame.width, height: searchBar.frame.height))
        nextPlaceholderLabel.text = placeholderTexts[placeholderIndex]
        nextPlaceholderLabel.textAlignment = .center
        nextPlaceholderLabel.font = searchBar.searchTextField.font
        nextPlaceholderLabel.textColor = searchBar.searchTextField.textColor
        searchBar.addSubview(nextPlaceholderLabel)

        UIView.animate(withDuration: 0.5, animations: {
            nextPlaceholderLabel.frame.origin.y = 0
            nextPlaceholderLabel.alpha = 1.0
            self.searchBar.layoutIfNeeded()
        }) { _ in
            self.searchBar.placeholder = self.placeholderTexts[self.placeholderIndex]
            nextPlaceholderLabel.removeFromSuperview()
        }

        placeholderIndex = (placeholderIndex + 1) % placeholderTexts.count
    }
}

The function updatePlaceholder() is supposed to achieve this animation, however my result is coming something like this:

It's nowhere close to what I want

What changes do I need to do here? Thanks in advance


Solution

  • There are various ways to approach this... here's one...

    The effect you want to replicate is animating individual words. When the placeholder text is changed, the current text animates down and fades out, word-by-word, and then the new text animates down (from above) and fades in, again word-by-word.

    So, when we update the placeholder text we can:

    • get the current text
      • split it into words (by space breaks)
      • create and position a label for each word
      • animate it down and fade it out

    enter image description here

    enter image description here

    enter image description here

    So we can see what's going on, I've outlined the labels in red, and they are not yet "fading-out."

    • get the new text
      • split it into words (by space breaks)
      • create and position a label for each word
      • animate it down and fade it in

    enter image description here

    enter image description here

    Again, so we can see what's going on, I've outlined these labels in blue, and visible on start. (They will eventually start with alpha-zero and then "fade-in.")

    The final step is to update the .placeholder text property, and remove all of the animated labels.

    Here is a quick example:

    class ViewController: UIViewController, UISearchBarDelegate {
        
        // start index at -1 so we get the first item -- [0] -- with the first increment
        var placeholderIndex = -1
        
        let placeholderTexts = [
            "What would you like to do?",
            "Check your Balance",
            "Send money to Mom",
            "Text 1",
            "Text 2",
            "Text 3",
        ]
        
        var searchBar: UISearchBar!
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            searchBar = UISearchBar()
            searchBar.placeholder = "Search...." // Set initial placeholder
            searchBar.searchBarStyle = .prominent
            searchBar.isTranslucent = true
            searchBar.delegate = self
            searchBar.backgroundImage = UIImage()
            searchBar.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(searchBar)
            
            NSLayoutConstraint.activate([
                searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
                searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
                searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
                searchBar.heightAnchor.constraint(equalToConstant: 70)
            ])
            
            Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(updatePlaceholder), userInfo: nil, repeats: true)
            
        }
        
        @objc func updatePlaceholder() {
            
            // use these values for the placeholder label frame origin
            //  if we can't get find it via code
            var baseX: CGFloat = 38.0
            var baseY: CGFloat = 25.0
            
            // try to find the placeholder label
            //  Apple did not make it public, so this is one approach
            //  note that if Apple changes the structure in the future, this may fail
            var placeholderLabelFrame: CGRect = .zero
            let tf: UITextField = searchBar.searchTextField
            for subview in tf.subviews {
                // Check if the subview is a UILabel and has the same text as the placeholder
                if let label = subview as? UILabel, label.text == tf.placeholder {
                    placeholderLabelFrame = label.frame
                }
            }
    
            if placeholderLabelFrame != .zero {
                let cvtFrame = searchBar.searchTextField.convert(placeholderLabelFrame, to: searchBar)
                baseX = cvtFrame.origin.x
                baseY = cvtFrame.origin.y
            }
    
            // used to offset the labels vertically
            var yOffset: CGFloat = 0.0
            let hideIncrement: CGFloat = 2.0
            let showIncrement: CGFloat = 8.0
    
            // for positioning the labels
            var currentX: CGFloat = 0
    
            var toHideWords: [String] = []
            var toShowWords: [String] = []
            
            var toHideLabels: [UILabel] = []
            var toShowLabels: [UILabel] = []
            
            // array of Y-positions for the toHide labels to animate to
            var targetY: [CGFloat] = []
    
            let sbFont = searchBar.searchTextField.font
            
            // split the current placeholder text into words
            let hidePlaceholder: String = searchBar.placeholder ?? ""
            toHideWords = hidePlaceholder.components(separatedBy: " ")
                
            currentX = baseX
            
            // create and position labels for each word
            for word in toHideWords {
                let wordLabel = UILabel()
                wordLabel.text = word + " "
                wordLabel.font = sbFont
                wordLabel.textColor = .gray
                wordLabel.sizeToFit()
                wordLabel.frame.origin = .init(x: currentX, y: baseY)
                currentX += wordLabel.frame.width
                toHideLabels.append(wordLabel)
                searchBar.addSubview(wordLabel)
            }
            
            // increment index into placeholderTexts
            placeholderIndex = (placeholderIndex + 1) % placeholderTexts.count
    
            // split the new placeholder text into words
            let showPlaceholder: String = placeholderTexts[placeholderIndex]
            toShowWords = showPlaceholder.components(separatedBy: " ")
            
            currentX = baseX
            
            // create and position labels for each word
            for word in toShowWords {
                let wordLabel = UILabel()
                wordLabel.text = word + " "
                wordLabel.font = sbFont
                wordLabel.textColor = .gray
                wordLabel.sizeToFit()
                wordLabel.frame.origin = .init(x: currentX, y: baseY)
                currentX += wordLabel.frame.width
                toShowLabels.append(wordLabel)
                searchBar.addSubview(wordLabel)
            }
    
            // we've added both sets of labels...
            //  so now we need to set the
            //      starting y-positions of the toShow labels
            //  and the
            //      ending y-positions of the toHide labels
            
            //  the toShow labels will be animated down *to* the baseY
            //  and faded in
            yOffset = showIncrement
            for wordLabel in toShowLabels {
                wordLabel.alpha = 0.0
                wordLabel.frame.origin.y = baseY - yOffset
                yOffset += showIncrement
            }
            
            // the toHide labels will be animated down *from* the baseY
            //  and faded out
            yOffset = baseY + hideIncrement
            for wordLabel in toHideLabels {
                wordLabel.alpha = 1.0
                wordLabel.frame.origin.y = baseY
                targetY.append(yOffset)
                yOffset += hideIncrement
            }
            
            // clear the current placeholder text
            self.searchBar.placeholder = ""
            
            // we need to reverse the order of the toHide labels
            //  because we want the animation to start at the right-end
            toHideLabels.reverse()
            targetY.reverse()
            
            var totalDur: Double = 0.0
            var relStart: Double = 0.0
            
            // all times in seconds (0.1 == 1/10th of a second, 0.05 = 1/20th, etc)
            var hideSpeed: Double = 0.1
            var hideInterval: Double = 0.05
            
            // pause between hiding the "current" placeholder and showing the "new" placeholder
            var pause: Double = 0.5
            
            var showSpeed: Double = 0.2
            var showInterval: Double = 0.1
            
            let numToHide: Double = Double(toHideLabels.count)
            let numToShow: Double = Double(toShowLabels.count)
            
            // total duration in seconds
            totalDur = (numToHide * hideSpeed) + pause + (numToShow * showSpeed)
            
            // keyFrame start times and durations are relative to the total duration
            //  so convert them to percentages
            hideSpeed /= totalDur
            hideInterval /= totalDur
            pause /= totalDur
            showSpeed /= totalDur
            showInterval /= totalDur
            
            UIView.animateKeyframes(withDuration: totalDur, delay: 0, options: [.calculationModeLinear], animations: {
                
                for (index, wordLabel) in toHideLabels.enumerated() {
                    UIView.addKeyframe(withRelativeStartTime: relStart, relativeDuration: hideSpeed, animations: {
                        wordLabel.alpha = 0.0
                        wordLabel.frame.origin.y = targetY[index]
                    })
                    relStart += hideInterval
                }
                
                relStart += pause
                
                for wordLabel in toShowLabels {
                    UIView.addKeyframe(withRelativeStartTime: relStart, relativeDuration: showSpeed, animations: {
                        wordLabel.alpha = 1.0
                        wordLabel.frame.origin.y = baseY
                    })
                    relStart += showInterval
                }
                
            }, completion: { _ in
                
                // set the new placeholder text
                self.searchBar.placeholder = showPlaceholder
                
                // remove all of the word labels
                for wordLabel in toShowLabels {
                    wordLabel.removeFromSuperview()
                }
                for wordLabel in toHideLabels {
                    wordLabel.removeFromSuperview()
                }
                
            })
            
        }
    }
    

    Looks like this when running:

    enter image description here