When writing an app it is not enough only to have it work, sometimes a developer should make the extra mile and also improve UX. Users are engaged to an app only if they find it smooth and intuitive. Small view animations can be a great addition for users. In this tutorial, I will try to animate state transitions on the login and the signup screen with a transition between views itself as well.
Let’s start with adding a container view that will hold information which screen should be presented(login and signup)
struct ContentView: View {
@State var loginShown: Bool = true
var body: some View {
VStack {
renderScreenChooseView
.padding(.bottom, 60)
if loginShown {
LoginView()
.transition(.insertToRight)
} else {
SignupView()
.transition(.insertFromRight)
}
Spacer()
}
}
var renderScreenChooseView: some View {
HStack {
renderSingleChoseView(text: "Login", showIndicator: loginShown)
.onTapGesture {
withAnimation {
loginShown = true
}
}
renderSingleChoseView(text: "Sign up", showIndicator: !loginShown)
.onTapGesture {
withAnimation {
loginShown = false
}
}
Spacer()
}
.padding()
}
func renderSingleChoseView(text: String, showIndicator: Bool) -> some View {
VStack {
Text(text)
if showIndicator {
Divider()
.transition(.insertFromRight)
}
Spacer()
}
.frame(width: 80, height: 50)
}
}
When we want to define a transition, we need also need to define a trigger for that transition, which, in our case would be a bool loginShown that will serve as a single source of truth which view should be presented. We have defined a custom transition that will serve to control how elements are shown on the screen. We will add two transitions(from left to right and vice-versa).
extension AnyTransition {
static var insertToRight: AnyTransition {
let insertion: AnyTransition = .move(edge: .leading)
.combined(with: .opacity)
let removal: AnyTransition = .move(edge: .trailing)
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
static var insertFromRight: AnyTransition {
let insertion: AnyTransition = .move(edge: .trailing)
.combined(with: .opacity)
let removal: AnyTransition = .move(edge: .leading)
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}
On our login screen, we should have two text fields with separators and a login button at the bottom of the screen. For a login button, we will use a view modifier which will have a scale effect with opacity.
struct LoginView: View {
@State var username = ""
@State var password = ""
var isLoginButtonEnabled: Bool {
withAnimation {
!username.isEmpty
&& !password.isEmpty
}
}
var body: some View {
VStack {
HStack {
Text("Welcome back! Please enter your credentials")
.font(.title)
.bold()
Spacer()
}
renderUsernameTextField
renderPasswordTextField
Spacer()
Button(action: {
}) {
Text("Login")
.frame(maxWidth: .infinity)
.frame(height: 40)
}
.padding()
.buttonStyle(AnimatedButtonViewModifier(isEnabled: isLoginButtonEnabled))
}
.padding()
}
var renderUsernameTextField: some View {
DividerTextField(text: $username, placeholder: "Username")
}
var renderPasswordTextField: some View {
DividerTextField(text: $password, placeholder: "Password")
}
}
Also, we have offloaded some logic to an external view which will add a separator after a text field. Also in the next lines a button view modifier can be found.
struct DividerTextField: View {
@Binding var text: String
var placeholder: String
var body: some View {
VStack {
TextField(placeholder, text: $text)
Divider()
}
}
}
For a button animation we will use a scale effect(button will grow bigger when pressed) and also opacity will be changed so it indicates an obvious user action and that something is happening on that press.
struct AnimatedButtonViewModifier: ButtonStyle {
var isEnabled = true
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(isEnabled ? Color.orange : Color.gray)
.foregroundColor(.white)
.cornerRadius(10.0)
.opacity(configuration.isPressed ? 0.7 : 1.0)
.scaleEffect(configuration.isPressed ? 1.1 : 1.0)
.transition(.opacity)
}
}
We will have three text fields, two checkmarks, and a button at the bottom of the screen on a signup screen. We will also add an animated checkmark view which will control a transition between checked and unchecked view. Animation of a checkmark will be offloaded and controlled by a self-driven class.
struct SignupView: View {
@State var email: String = ""
@State var password: String = ""
@State var repeatPassword: String = ""
@State var areTermsAccepted = false
@State var userAgreementsAccepted = false
var isSignupButtonEnabled: Bool {
withAnimation {
areTermsAccepted
&& userAgreementsAccepted
&& !email.isEmpty
&& !password.isEmpty
&& !repeatPassword.isEmpty
}
}
var body: some View {
VStack {
HStack {
Text("First time here?")
.font(.title)
.bold()
Spacer()
}
renderUsernameTextField
renderPasswordTextField
renderRepeatPasswordTextField
renderTermsAndConditionsView
Spacer()
Button(action: {
}) {
Text("Signup")
.frame(maxWidth: .infinity)
.frame(height: 40)
}
.padding()
.buttonStyle(AnimatedButtonViewModifier(isEnabled: isSignupButtonEnabled))
.disabled(!isSignupButtonEnabled)
}
.padding()
}
var renderUsernameTextField: some View {
DividerTextField(text: $email, placeholder: "Username")
}
var renderPasswordTextField: some View {
DividerTextField(text: $password, placeholder: "Password")
}
var renderRepeatPasswordTextField: some View {
DividerTextField(text: $repeatPassword, placeholder: "Repeat password")
}
var renderTermsAndConditionsView: some View {
VStack {
TermsView(accepted: $areTermsAccepted, text: "I accept terms and conditions")
TermsView(accepted: $userAgreementsAccepted, text: "I accept user agreements")
}
}
}
struct TermsView: View {
@Binding var accepted: Bool
var text: String
var body: some View {
HStack {
Text(text)
.font(.system(size: 12))
Spacer()
AnimatedCheckView(checkAccepted: $accepted, transitionType: .scale)
}
}
}
struct AnimatedCheckView: View {
@Binding var checkAccepted: Bool
var transitionType: AnyTransition = .scale
var body: some View {
/// SwiftUI needs to have a condition so it can know how to render it with transition!
if checkAccepted {
renderCheckmark(accepted: true)
} else {
renderCheckmark(accepted: false)
}
}
private func renderCheckmark(accepted: Bool) -> some View {
Image(accepted ? "checkmark-true" : "checkmark-false")
.resizable()
.transition(transitionType)
.frame(width: 18, height: 18)
.onTapGesture {
withAnimation {
checkAccepted.toggle()
}
}
}
}
When trying to animate views, the important thing is to control an animation by some flag and define a transition for views affected by this animation.

