Visualize Combine Magic with SwiftUI — Part 5

SwiftUI ViewModifier, Animation, and Transition

Kevin Cheng
5 min readNov 17, 2019

In the previous chapter, we’ve successfully built a playground that shows Combine operators in action. We learned the differences between Zip and CombineLatest by comparing them side by side in the playground.

In this chapter, we want to improve the playground and apply a few more features from SwiftUI. The goal is to make Combine’s behaviors stand out even higher with these features. You’ll notice how much nicer the playground becomes at the end of this chapter.

ViewModifier

A good friend of mine suggested that in StreamView, subscribe and cancel buttons should look more like buttons rather than hyperlinks. This is a perfect starting point for this chapter, introducing ViewModifier. ViewModifier is simply a style template where you use to wrap around any SwiftUI views. Below is how we use ViewModifier to decorate buttons in the playground.

content.font(.footnote)
.padding(10)
.foregroundColor(Color.white)
.frame(minWidth: 80)
.background(backgroundColor)
.cornerRadius(12)

SwiftUI’s declarative style is that awesome and readable. By going over each sub-modifier line by line, you get a good idea of what this button is going to look like with its fonts, paddings, foreground color, round corners and given background color.

Below is how you apply a modifier to the subscription button.

Button("Subscribe") {
........
}.modifier(ButtonModifier(backgroundColor: Color.blue))

Ta-da:

Much nicer now~

Animation & Transition

We’ve mentioned in chapter 1, it would be great if we can add some animation to the playground. In SwiftUI, a basic animation can be achieved by applying animation and transition modifiers to your views. Let’s try adding an animation to the TunnelView.

var body: some View {
HStack(spacing: verticalPadding) {
Spacer()
ForEach(streamValues.reversed(), id: \.self) { texts in
CircularTextArrayView(texts: texts)
}
}.animation(.easeInOut)
....................................

Animation method returns a view with given animation(easeInOut in this example). Whenever changes happen within this view, SwiftUI will perform animation based on the given guideline. There are a handful of default animations you can play with.

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)extension Animation {public static func easeInOut(duration: Double) -> Animationpublic static var easeInOut: Animation { get }public static func easeIn(duration: Double) -> Animationpublic static var easeIn: Animation { get }public static func easeOut(duration: Double) -> Animationpublic static var easeOut: Animation { get }public static func linear(duration: Double) -> Animationpublic static var linear: Animation { get }public static func timingCurve(_ c0x: Double, _ c0y: Double, _ c1x: Double, _ c1y: Double, duration: Double = 0.35) -> Animation
}

This is what StreamView looks like now with animation.

You can now (kind of) see the circular views slowly appear, disappear from the screen, and transition with opacity.

Although it is nice to have some effects on CircularView, it doesn’t really highlight each stream values entrance. In our wish list from chapter 1, we wanted the CircularView to enter from the leading edge and exit into the trailing edge of the tunnel. Since the default transition, opacity doesn’t satisfy our need, let’s try some other transitions.

Slide Transition

There is a list of default transitions we can find under AnyTransition type in Apple documentation. slide sounds like what we want. So, let’s give it a try.

ForEach(streamValues.reversed(), id: \.self) { texts in     CircularTextArrayView(texts: texts).transition(.slide)
}.animation(.easeInOut)

hum…views did slide but did not start sliding from where we wanted. It seems like the default slide transition only x-offset with the degree to the size the same from its view size. What we want is to offset from the leading edge of its parent view.

To solve this, there are 3 things we need to figure out.

First, how do we create a custom slide transition?

The answer is, offset transition. We can provide a custom number of offset for CircularView to slide from and disappear to.

Second, say we know the offset value to appear from and disappear to, how do we combine these 2 offset values together?

We can apply asymmetric transition on insertion and removal separately.

CircularTextArrayView(texts: texts) .transition(.asymmetric(insertion: .offset(x: -distance_to_left_edge, y: 0),removal: .offset(x: distance_to_right_edge, y: 0)))

Finally, how far do we offset?

We can always offset with the screen size( UIScreen.main.bounds.size), but it is not scalable. We don’t want to handle portrait and landscape modes differently and we want this view to be adaptive enough for all sizes of sub-views within any view trees. Let’s look deeper and think of a better solution.

What we want is a way to get the size of view’s container view, then we’ll be able to simply offset from and to outside of its container view.

GeometryReader is a way for us to get that size. You can learn more about GeometryReader from this article, GeometryReader to the Rescue.

CircularTextArrayView(texts: texts) .transition(.asymmetric(insertion: .offset(x: -reader.size.width, y: 0), removal: .offset(x: reader.size.width, y: 0)))

Now we got a nice series of animated transitions.

While this works great for Zip, we notice an issue with CombineLatest. When there are too many items in the TunnelView, some new items are being left out of bounds from the container view. The below video describes this issue.

This is not too hard to fix. We just need to extend the width of our tunnel view when circular views’ total width plus spacing go over the width from GeometryReader. Also, we’ll need to offset the tunnel view toward the right side to leave new values in the screen.

HStack { // The Tunnel ...............................
}.frame(width: self.tunnelWidth(with: reader.size.width), alignment: .trailing)
.offset(x: self.tunnelOffset(with: reader.size.width))

We just need to apply the correct width and offset to tunnel view’s HStack .

func tunnelWidth(with screenWidth: CGFloat) -> CGFloat {
max(screenWidth, (radius * 2 + spacing) *
CGFloat(streamValues.count))
}

For the tunnel’s width, we take either the width of the container view or the needed total width of all circular views plus the gaps in between, depending on whichever is wider.

func tunnelOffset(with screenWidth: CGFloat) -> CGFloat {
(tunnelWidth(with: screenWidth) - screenWidth) / 2
}

For the offset, in the case when the tunnel view is wider than its container, the wider parts are extended to both leading and trailing sides equally. What we want is to move all the extended part toward the trailing side so that we always leave newer values on the left side.

Here is the final result

Our animated tunnel view with slide animation now looks like below.

Here’s the source code to Combine Magic with SwiftUI repo.

Next Series: Data-Driven Combine

Finally, we’ve made the playground capable of demonstrating Combine’s operations in clear and intuitive manners. This series has come to an end.

I can’t wait to move into the next series. In the next series, we will aim to serialize/deserialize all the streams and operators you have seen from this series. Once we achieve that, not only you’ll be able to persist business logic running with Combine, you’ll also be able to modify logic remotely by feeding serialized Combine operators.

Stay tuned for the new series, and as always, please feel free to leave comments about this series.

Update: Data-Driven Combine series is released now. Welcome to tap the link below and learn how business logic can be preserved and distributed through a series of Codable structs and enums that represent reactive chains.

--

--