Categories
iOS Swift SwiftUI

Examples of animating SF symbols in SwiftUI

WWDC’23 introduced SF symbol animations. There are 8 different animation presets: appear, disappear, bounce, scale, pulse, variable color, and replace. Each of these presets have a dedicated use-cases. Appear animation is used when a symbol is shown in the UI for the first time. Disappear when a symbol is removed from the UI. Bounce is suitable for communicating to a user that an action was triggered, or it was completed successfully. Scale animation is suitable for highlighting elements in the UI, like when hovering on the element. It could also be used to let the user know that an action has taken a place (think about a button which has pressed down state). Pulse animation is an excellent way to show that some action is ongoing. While recording a video, the record button’s symbol pulses. Variable color animation communicates a state which changes over time (Wi-Fi signal strength). Replace animation is useful for communicating that the function of a symbol has changed. Think about a play button changing to a pause button.

Symbol animations are applied with symbolEffect(_:options:value:) for discrete effects, symbolEffect(_:options:isActive:) for indefinite effects and contentTransition(_:) and transition() view modifiers with a new symbolEffect. Discrete effects are on-off effects, whereas indefinite effects change the symbol indefinitely and need to be explicitly removed. Bounce, pulse, and variable color support discrete effects and pulse, variable color, scale, appear, disappear support indefinite effects. Appear and disappear also support transition effects, and replace supports a content transition effect. Therefore, we need to keep in mind this information when picking the view modifier.

Next, let’s look at some of the examples and how to apply these view modifiers.

Appear and Disappear

struct AppearDisappearView: View {
@State private var isDrizzleHidden = true
var body: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: "cloud.fill")
Image(systemName: "cloud.drizzle.fill")
.symbolEffect(.disappear, isActive: isDrizzleHidden)
Image(systemName: "cloud.heavyrain.fill")
}
.imageScale(.large)
HStack {
Image(systemName: "cloud.fill")
if !isDrizzleHidden {
Image(systemName: "cloud.drizzle.fill")
.transition(.symbolEffect(.automatic))
}
Image(systemName: "cloud.heavyrain.fill")
}
.imageScale(.large)
Button("Appear/Disapper") {
isDrizzleHidden.toggle()
}
}
}
}

Bounce

struct BounceView: View {
@State private var value = 0
var body: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: "snowflake")
.symbolEffect(.bounce, value: value)
Image(systemName: "snowflake")
.symbolEffect(.bounce, options: .speed(0.1), value: value)
Image(systemName: "snowflake")
.symbolEffect(.bounce, options: .repeat(2), value: value)
}
.imageScale(.large)
Button("Bounce") { value += 1 }
}
}
}

Scale

struct ScaleView: View {
@State private var isActive = false
var body: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: "drop.fill")
.symbolEffect(.scale.up, isActive: isActive)
Image(systemName: "drop.fill")
.symbolEffect(.scale.down, options: .speed(0.1), isActive: isActive)
Image(systemName: "drop.fill")
.symbolEffect(.scale.down, options: .speed(5), isActive: isActive)
}
.imageScale(.large)
Button("Scale") { isActive.toggle() }
}
}
}
view raw ScaleView.swift hosted with ❤ by GitHub

Pulse

struct PulseView: View {
@State private var isActive = false
var body: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: "moonphase.first.quarter")
.symbolEffect(.pulse, isActive: isActive)
Image(systemName: "moonphase.first.quarter")
.symbolEffect(.pulse, options: .speed(0.1), isActive: isActive)
Image(systemName: "moonphase.first.quarter")
.symbolEffect(.pulse, options: .speed(5), isActive: isActive)
}
.imageScale(.large)
Button("Pulse") { isActive.toggle() }
}
}
}
view raw PulseView.swift hosted with ❤ by GitHub

Variable Color

struct VariableColorView: View {
@State private var isActive = false
var body: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: "rainbow")
.symbolEffect(.variableColor, options: .speed(0.1), isActive: isActive)
Image(systemName: "rainbow")
.symbolEffect(.variableColor.iterative, options: .speed(0.1), isActive: isActive)
Image(systemName: "rainbow")
.symbolEffect(.variableColor.iterative.reversing, options: .speed(0.1), isActive: isActive)
}
.symbolRenderingMode(.multicolor)
.imageScale(.large)
Button("Variable Color") { isActive.toggle() }
}
}
}

Replace

struct ReplaceView: View {
@State private var isActive = false
var body: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: isActive ? "pause.circle.fill" : "play.circle.fill")
.contentTransition(.symbolEffect(.replace))
Image(systemName: isActive ? "pause.circle.fill" : "play.circle.fill")
.contentTransition(.symbolEffect(.replace.offUp))
Image(systemName: isActive ? "pause.circle.fill" : "play.circle.fill")
.contentTransition(.symbolEffect(.replace.upUp))
}
.imageScale(.large)
Button("Replace") { isActive.toggle() }
}
}
}

Example Project

SymbolsAnimationExample (Xcode 15 beta 6)

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
Foundation iOS Swift

Replacing multiple text tokens in Swift

Let’s see how to replace multiple tokens in string. As an example problem to solve we will use this string:
The quick <color_1> <animal_1> jumps over the lazy <animal_2>

String extension for replacing tokens

Token’s format is < text _ numbers > what can be turned into regular expression: <[:alpha:]+_{1}[:digit:]+>.

We’ll extend string and add a function what takes in regular expression and closure responsible of providing replacement strings. For finding tokens, we’ll use NSRegularExpression and get all the matches in the string. Next step is to reverse enumerate matches and replace tokens. Reverse enumerating is required it ensures that token ranges are constant. If we would start replacing from the first match, then all the succeeding ranges should be shifted based on the length difference of all the preceding tokens and replacements. In this case reduce is convenient because we can enumerate all the matches and then mutating the copy of the initial string with very few lines. Another aspect to note is that NSRegularExpression uses NSRange instead of <a rel="noreferrer noopener" aria-label="RangeRange<String.Index>. Therefore we need to convert ranges from one type to another making sure character indexes match.
This function can now be used with custom logic when providing replacements. For example: we can have a simple mapping or even returning the same replacement string.

let text = "The quick <color_1> <animal_1> jumps over the lazy <animal_2>"
let replacementMap = ["<animal_1>": "fox", "<animal_2>": "dog", "<color_1>": "brown"]
extension String {
func replacingOccurrences(matchingPattern pattern: String, replacementProvider: (String) -> String?) -> String {
let expression = try! NSRegularExpression(pattern: pattern, options: [])
let matches = expression.matches(in: self, options: [], range: NSRange(startIndex..<endIndex, in: self))
return matches.reversed().reduce(into: self) { (current, result) in
let range = Range(result.range, in: current)!
let token = String(current[range])
guard let replacement = replacementProvider(token) else { return }
current.replaceSubrange(range, with: replacement)
}
}
}
let finalString1 = text.replacingOccurrences(matchingPattern: "<[:alpha:]+_{1}[:digit:]+>", replacementProvider: { replacementMap[$0] })
let finalString2 = text.replacingOccurrences(matchingPattern: "<[:alpha:]+_{1}[:digit:]+>", replacementProvider: { _ in "REPLACEMENT" })
print(text)
print(finalString1) // The quick brown fox jumps over the lazy dog
print(finalString2) // The quick REPLACEMENT REPLACEMENT jumps over the lazy REPLACEMENT
String extension replacing tokens matching a pattern.

Summary

When we would like to do multiple replacements in a string, then one of the approaches is to get all the replacement ranges and then reverse enumerating the ranges and making replacements. In this way we can avoid having complex code trying to adjust based on the length difference of the source and replacement string.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.