2-step animated transition in SwiftUI - swift

I'd like to make a custom view transition in SwiftUI, animated in 2 steps:
Presenting child
Fade out parent (opacity to 0)
(Then) fade in child and slide from bottom
Dismissing child
Fade out child and slide to bottom
(Then) fade in parent
Demo (created artificially)
Base code:
struct TransitionTestView: View {
#State var presented = false
var body: some View {
ZStack {
if presented {
Button("Hide", action: { presented = false })
.foregroundColor(.black)
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.purple)
}
else {
Button("Show", action: { presented = true })
.foregroundColor(.black)
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.orange)
}
}
.padding(30)
}
}
I've experimented with:
Different placements of the animation modifier: to the ZStack, or to each element individually
Applying the animation directly to the transition: .opacity.animation(.linear(duration: 1).delay(1))
But I couldn't make it work. Any help appreciated, thanks a lot!

A possible approach can be using transaction transformation by injecting additional delay to main animation for each phase.
Tested with Xcode 13.4 / iOS 15.5
Main part:
ZStack {
VStack { // << animating container
if presented {
Button("Hide", action: { presented = false })
.foregroundColor(.black)
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.purple)
.transition(.move(edge: .bottom))
}
}
.transaction { // << transform animation for direct transition
$0.animation = $0.animation?.delay(presented ? 1.2 : 0)
}
VStack { // << animating container
if !presented {
Button("Show", action: { presented = true })
.foregroundColor(.black)
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.orange)
.transition(.opacity)
}
}
.transaction { // << transform animation for reverse transition
$0.animation = $0.animation?.delay(presented ? 0 : 1.2)
}
}
.animation(.linear(duration: 1), value: presented) // << main !!
Test module on GitHub

Related

SwiftUI Button content uses its own animation where it shouldn't

I have a custom transition to present/dismiss a custom sheet. My problem is that the button content is using its own animation, where it should just follow the rest:
See how the "OK" button is jumping to the bottom in the dismiss animation. It should just be following the rest of the sheet.
Full code:
import SwiftUI
#main
struct SwiftUITestsApp: App {
var body: some Scene {
WindowGroup {
SheetButtonAnimationTestView()
}
}
}
struct SheetButtonAnimationTestView: View {
#State private var sheetPresented = false
var body: some View {
ZStack(alignment: .bottom) {
Button("Present sheet", action: { sheetPresented = true })
.frame(maxWidth: .infinity, maxHeight: .infinity)
if sheetPresented {
Sheet(title: "Sheet", dismiss: { sheetPresented = false })
.transition(.move(edge: .bottom))
}
}
.animation(.easeOut(duration: 2), value: sheetPresented)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.edgesIgnoringSafeArea(.all))
}
}
struct Sheet: View {
var title: String
var dismiss: () -> ()
var body: some View {
VStack(spacing: 16) {
HStack {
Text(title)
.foregroundColor(.white)
Spacer()
Button(action: dismiss) {
Text("OK").font(.headline.bold()).foregroundColor(.blue)
.padding(10)
.background(Capsule().fill(Color.white))
}
}
Text("This is the sheet content")
.foregroundColor(.white)
.frame(height: 300)
.frame(maxWidth: .infinity)
}
.padding(24)
.frame(maxWidth: .infinity)
.background(
Rectangle().fill(Color.black).edgesIgnoringSafeArea(.bottom)
)
.ignoresSafeArea(.container, edges: .bottom) // allows safe area for keyboard
}
}
How to make the button follow the sheet animation?
Tested on iOS 16.0.3, iPhone 11, XCode 14.1
Seems like a bug in the library. You can add a slight delay in the button action, which will solve the issue:
Button(action: {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) {
dismiss()
}
}) {
Text("OK").font(.headline.bold()).foregroundColor(.blue)
.padding(10)
.background(Capsule().fill(Color.white))
}
Or if you don't want the click animation, you can remove the button and do the following:
Text("OK").font(.headline.bold()).foregroundColor(.blue)
.padding(10)
.background(Capsule().fill(Color.white))
.onTapGesture {
dismiss()
}

How do I make an HStack of SwiftUI buttons have height?

I have a simple SwiftUI row of buttons to mimic a keyboard layout. Just can't get the buttons to fill the height of the HStack. I can't tell where I need to give something height so it will look like a keyboard. No matter where I try to add height, the buttons still are small.
struct KeyboardView: View {
let rows = [String.keyboardFKeyRow, String.keyboardNumKeyRow, String.alpha1KeyRow, String.alpha2KeyRow, String.alpha3KeyRow, String.arrowKeyRow]
var body: some View {
VStack() {
KeyRow(labels: String.keyboardFKeyRow).frame(height: 100)
KeyRow(labels: String.keyboardNumKeyRow).frame(height: 100)
KeyRow(labels: String.alpha1KeyRow).frame(height: 100)
KeyRow(labels: String.alpha2KeyRow).frame(height: 100)
KeyRow(labels: String.alpha3KeyRow).frame(height: 100)
KeyRow(labels: String.arrowKeyRow).frame(height: 100)
}
}
}
struct KeyRow: View {
var labels: [String]
var body: some View {
HStack() {
Spacer()
ForEach(labels, id: \.self) { label in
Button {
print("Key Clicked")
} label: {
Text(label.capitalized)
.font(.body)
.frame(minWidth: 60, maxWidth: 80, minHeight: 80, maxHeight: .infinity)
}
}
Spacer()
}
}
}
The .bordered button style on macOS has a hard-coded size and refuses to stretch vertically. If you need a different button height, either use the .borderless style or create your own ButtonStyle or PrimitiveButtonStyle, and (either way) draw the background yourself. For example, I got this result:
from this playground:
import PlaygroundSupport
import SwiftUI
PlaygroundPage.current.setLiveView(HStack {
// Default button style, Text label.
Button("a", action: {})
.background { Rectangle().stroke(.mint.opacity(0.5), lineWidth: 1).clipped() }
.frame(maxHeight: .infinity)
// Default button style, vertically greedy Text label.
Button(action: {}, label: {
Text("b")
.frame(maxHeight: .infinity)
})
.background { Rectangle().stroke(.mint.opacity(0.5), lineWidth: 1).clipped() }
.frame(maxHeight: .infinity)
Button(action: {}, label: {
Text("c")
.fixedSize()
.padding()
.frame(maxHeight: .infinity)
.background {
RoundedRectangle(cornerRadius: 10)
.fill(Color(nsColor: .controlColor))
}
}).buttonStyle(.borderless)
.background { Rectangle().stroke(.mint.opacity(0.5), lineWidth: 1).clipped() }
.frame(maxHeight: .infinity)
}
.background { Rectangle().stroke(.red.opacity(0.5), lineWidth: 1).clipped() }
.frame(height: 100)
)

SwiftUI set blur on scrollview children except top most child

How would I set a blur on all of a scrollview's children (inside a vstack) except for the topmost visible child in the scrollview?
I've tried something like the following but it does not work.
ScrollViewReader { action in
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(lyrics, id: \.self) { lyric in
Text(lyric)
.onAppear {
blurOn = 0.0
}
.onDisappear {
blurOn = 5.0
}
.padding()
.blur(radius: blurOn)
}
}
.font(.system(size: 20, weight: .medium, design: .rounded))
.padding(.bottom)
}
.cornerRadius(10)
.frame(height: 130)
}
After thinking about it, I guess I could put the VStack in a ZStack and place a 0 opacity view on the bottom with a blur...just wondering if there's a better way to do this?

How to transition a view (i.e List) to appear from a certain point on the screen in SwiftUI?

I have a list that occupies 60% of the top of the screen. I want the list to animate by sliding in from the bottom of that 60% during the transition instead of the bottom of the phone screen. If you take a look at my code, you will get an idea on what im talking about...
struct ContentView: View {
let controls = ["backward.fill", "play.fill", "forward.fill"]
#State private var showingList = false
var body: some View {
GeometryReader { geo in
VStack {
Group {
if showingList {
List(0..<50) {
Text("\($0)")
}
.transition(.move(edge: .bottom))
} else {
Spacer()
}
}
.frame(maxWidth: geo.size.width, maxHeight: geo.size.height * 0.6)
//I want the list to slide in from the bottom of the top frame that takes 60% of the height.
HStack {
ForEach(controls, id: \.self) {
Image(systemName: $0)
.foregroundColor(.white)
}
}
.frame(maxWidth: geo.size.width, maxHeight: geo.size.height * 0.4)
.onTapGesture {
withAnimation {
showingList.toggle()
}
}
}
//I don't want the view to slide in from the bottom of the phone right here
}
}
}
If I understand you correctly, you want the list to be hidden behind your controls while it appears. You can try adding a background behind the controls:
HStack {
ForEach(controls, id: \.self) {
Image(systemName: $0)
.foregroundColor(.white)
}
}
.frame(maxWidth: geo.size.width, maxHeight: geo.size.height * 0.4)
.background(Color(uiColor: .systemBackground)) // <- add a background here to hide the list during transition
.onTapGesture {
withAnimation(.linear(duration: 4)) {
showingList.toggle()
}
}
}

Modal picker not scrolling right SwiftUI

I created a modal but it seems to have a bug on the selection. When scrolling the left, it scrolls the right, I have to go to the very edge of the left to be able to scroll, this is how it looks:
import SwiftUI
struct ContentView: View {
#State var showingModal = false
#State var hours: Int = 0
#State var minutes: Int = 0
var body: some View {
ZStack {
VStack {
Button("Show me"){
self.showingModal = true
}
if $showingModal.wrappedValue {
VStack(alignment: .center) {
ZStack{
Color.black.opacity(0.4)
.edgesIgnoringSafeArea(.vertical)
// this one is it
VStack(spacing: 20) {
Text("Time between meals")
.bold().padding()
.frame(maxWidth: .infinity)
.background(Color.yellow)
.foregroundColor(Color.white)
HStack {
Spacer()
VStack {
Picker("", selection: $hours){
ForEach(0..<4, id: \.self) { i in
Text("\(i) hours").tag(i)
}
}
.frame(width: 150, height: 120)
.clipped()
}
VStack {
Picker("", selection: $minutes){
ForEach(0..<60, id: \.self) { i in
Text("\(i) min").tag(i)
}
}
.frame(width: 150, height: 120)
.clipped()
}
}
Spacer()
Button(action: {
self.showingModal = false
}){
Text("Close")
} .padding()
}
.frame(width:300, height: 300)
.background(Color.white)
.cornerRadius(20).shadow(radius: 20)
}
}
}
}
}
}
}
How can I fix that little bug? I tried playing around with the layout but no use... any help would be appreciated
What if I told you the reason your Picker not working was this line?
.cornerRadius(20).shadow(radius: 20)
Unfortunately, SwiftUI is still quite buggy and sometimes it doesn't do what it is supposed to do and especially Pickers are not that reliable. I guess we'll need to wait and see the next iteration of SwiftUI, but for now you can replace that line with the code below:
.mask(RoundedRectangle(cornerRadius: 20))
.shadow(radius: 20)
There are just modifiers which affect all view hierarchy (ie. all subviews) that can change resulting layout/presentation/behaviour. And .cornerRadius and .shadow are such type modifiers.
The solution is to apply (as intended) those modifiers only to entire constructed view, and here it is
.compositingGroup() // <<< fix !!
.cornerRadius(20).shadow(radius: 20)
where .compositionGroup is intended to make above view hierarchy flat rendered and all below modifiers applied to only to that flat view.