SwiftUI translate and rotate view with matchedGeometryEffect strange behavior - swift

I'm trying to simulate dealing cards in SwiftUI. I'm testing with one card, and the goal is to animate the card in the center (image1) to one of the sides (image2). I would like that the animation would rotate and translate the card simultaneously, but with this code the card rotates immediately without animation and then it translates animatedly. Any idea to get the rotation and translation effects simultaneously into the animation?
import SwiftUI
struct Card: Identifiable {
var id: String {
return value
}
let value: String
var dealt: Bool = false
}
struct CardView: View {
let card: Card
var flipped: Bool = false
var body: some View {
ZStack {
Color.white
Text("\(card.value)")
.padding(4)
Color.red.opacity(flipped ? 0.0 : 1.0)
}
.border(.black, width: 2)
}
}
struct CardsTableView: View {
#Namespace private var dealingNamespace
#State var card = Card(value: "1", dealt: false)
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.clear)
.border(.black, width: 2)
.padding(10)
VStack {
ZStack {
centerCard
lateralCard
}
Spacer()
Button {
withAnimation(.linear(duration: 1)) {
card.dealt.toggle()
}
} label: {
Text("Deal")
}
.padding()
}
}
}
var centerCard: some View {
VStack {
Spacer()
if !card.dealt {
CardView(card: card)
.frame(width: 40, height: 70)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
}
Spacer()
}
}
var lateralCard: some View {
HStack {
Spacer()
if card.dealt {
CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.frame(width: 40, height: 70)
.rotationEffect(.degrees(-90))
.transition(AnyTransition.asymmetric(insertion: .identity, removal: .opacity))
}
}
.padding(.trailing, 20)
}
}

The matchedGeometryEffect modifier doesn't know about the rotationEffect modifier, so neither view's rotation is animated during the transition. I'll explain how to get the animation you want in two ways: using transitions and using “slots”. Both solutions produce this animation:
Using transitions
You can use a custom .modifier transition to animate the rotation. I wouldn't do it this way, but since it has a similar structure as the code you posted, I'll explain it first.
For the sake of this answer, let's simplify CardView:
struct CardView: View {
var body: some View {
Text("C")
.foregroundColor(.black)
.padding()
.background(Color.white)
.border(Color.black, width: 1)
}
}
To animate rotation, we need a ViewModifier type that applies the rotation effect:
struct CardRotationModifier: ViewModifier {
var angle: Angle
func body(content: Content) -> some View {
content.rotationEffect(angle)
}
}
Here's CardTableView:
struct CardTableView: View {
#Namespace var namespace
#State var isSide = false
var body: some View {
ZStack {
VStack {
if !isSide {
topCard
}
Spacer()
}
HStack {
Spacer()
if isSide {
sideCard
}
}
}
.padding()
.background(Color.mint)
.onTapGesture {
withAnimation(.linear) {
isSide.toggle()
}
}
}
}
And finally here are the top and side card views:
extension CardTableView {
var topCard: some View {
CardView()
.matchedGeometryEffect(id: 0, in: namespace)
.transition(
.modifier(
active: CardRotationModifier(angle: .degrees(90)),
identity: CardRotationModifier(angle: .zero)))
}
var sideCard: some View {
CardView()
.matchedGeometryEffect(id: 0, in: namespace)
.transition(
.modifier(
active: CardRotationModifier(angle: .zero),
identity: CardRotationModifier(angle: .degrees(90))))
}
}
Note that the side card doesn't have a .rotationEffect. Instead, both cards have a transition that applies CardRotationModifier. SwiftUI applies the active modifier at the start of an entrance transition and the end of an exit transition. It applies the identity modifier at the end of an entrance transition, the start of an exit transition, and the entire time the view is “at rest” (present and not transitioning). So the top card normally has rotation zero, and the side card normally has rotation 90°, and each card is animated to the other's rotation during a transition.
What I don't like about this solution is that the transitions are configured specifically for moving a card between the top and side positions. The transition on the top position knows about the rotation of the side position, and vice versa. So what if you want to add a left-side position with a rotation of -90°? You've got a problem. Now you need to dynamically set the transition of each position based on where the card is moving from and to. Every position needs to know details of every other position, so it can be O(N) work to add another position.
Using slots
Instead, I would use what I think of as “slots”: put a hidden view at each possible position (“slot”) of a card. Then, use a view with a persistent identity to draw the card, and tell that persistent view to match the geometry of whichever slot it should occupy.
So, we need a way to identify each slot:
enum Slot: Hashable {
case top
case side
}
Now CardTableView lays out a subview for each slot, and a view for the card:
struct CardTableView: View {
#Namespace var namespace
#State var isSide = false
var body: some View {
ZStack {
topSlot
sideSlot
card
}
.padding()
.background(Color.mint)
.onTapGesture {
withAnimation(.linear) {
isSide.toggle()
}
}
}
}
Here are the slot subviews:
extension CardTableView {
var topSlot: some View {
VStack {
CardView()
.hidden()
.matchedGeometryEffect(id: Slot.top, in: namespace)
Spacer()
}
}
var sideSlot: some View {
HStack {
Spacer()
CardView()
.hidden()
.matchedGeometryEffect(id: Slot.side, in: namespace)
}
}
}
And here is the card subview:
extension CardTableView {
var card: some View {
CardView()
.rotationEffect(isSide ? .degrees(90): .zero)
.matchedGeometryEffect(
id: isSide ? Slot.side : Slot.top,
in: namespace, isSource: false)
}
}
Notice that now there are no transitions anywhere, and none of the slots knows anything about the other slots. If you want to add another slot, it's a matter of defining another slot subview, adding that new slot subview to the CardTableView ZStack, and updating the card subview to know how to pose itself in the new slot. None of the existing slot subviews are affected. It's O(1) work to add a new slot.

Related

Can you control the progress of a SwiftUI animation

Is it possible to control the progress of withAnimation
Ideal API would look something like this
withAnimation(progress: percentDragged) { showSecondView.toggle() }
Here's a full code example of the above line in context.
The goal is to smoothly slide between different app states with a user controlled gesture.
struct ContentView: View {
#State var showSecondView = false
var body: some View {
VStack {
ZStack {
if !showSecondView {
Text("First view")
.padding()
.background(Color.blue)
.transition(.slide)
} else {
Text("Second view")
.padding()
.background(Color.red)
.transition(.slide)
}
}
}
.gesture(
DragGesture()
.onChanged { value in
let percentDragged = value.translation.width / UIScreen.main.bounds.width
// Here I want to gradually slide (interpolate) between the two states
// Ideal API would look like this:
withAnimation(progress: percentDragged) { showSecondView.toggle() }
}
)
}
}
Yes.. I know I can trigger an animation whenever I want (like this). but that's not what I'm looking for! I want to control the progress
if percentDragged > 0.5 {
withAnimation { showSecondView.toggle() }
}
Yes.. I also know I could manually store a #State var offset: Double and control my own slide.
This just gets nasty when my app state is tied to tons of different visual changes. Views appearing/disappearing, alignments shifting, etc.

SwiftUI how do I temporarily animate a view color's foregroundColor?

When a View is pressed I know through a model button.isSelected. How do I animate the view's foreground color, similar to the IOS calculators button press animation?
Something like:
White -> Grey -> White
struct ButtonView: View {
let button: ViewModel.Button
var body: some View {
let shape = Rectangle()
ZStack {
shape.fill().foregroundColor(button.isSelected ? Color.gray : Color.white)
.animation(Animation.linear(duration: 0.01))
.border(Color.black, width: 0.33)
Text(button.content)
.font(Font.system(size:32))
}
}
}
I think there are many ways to do this.
Among them, I will write an example using DispatchQueue.main.asyncAfter()
struct ContentView: View {
#State private var isSelected: Bool = false
var body: some View {
VStack {
Button {
isSelected = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2 ) {
// To change the time, change 0.2 seconds above
isSelected = false
}
} label: {
Text("Button")
.foregroundColor(isSelected ? Color.red : Color.blue)
}
}
}
}
While DispatchQueue.main.asyncAfter() will work as Taeeun answered, note how the calculator app doesn't use a set delay. Instead, it changes color when the finger presses down, then reverts back upon release.
So, you probably want something like ButtonStyle.
struct ContentView: View {
var body: some View {
ButtonView()
}
}
struct CalculatorButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding() /// no need to use `shape` + `ZStack`, normal padding is ok
.background(configuration.isPressed ? Color.gray : Color.white) /// use `isPressed` to determine if button is currently pressed or not
.animation(Animation.linear(duration: 0.01))
.cornerRadius(10)
}
}
struct ButtonView: View {
var body: some View {
ZStack {
Color.black /// for testing purposes (see the button better)
Button {} label: {
Text("Button")
.font(.system(size: 32))
}
.buttonStyle(CalculatorButtonStyle()) /// apply the style
}
}
}
Result:

SwiftUI onTapGesture called only once

I am working on this audio player with multiple view components in it.
I added a way to hide/show the top view and the bottom view when we click anywhere in the middle view.
Before it was working fine, but recently when I tried again, it only dismiss it and doesn't trigger the onTapGesture again.
I believe the only difference with before is that the view is presented instead of pushed in a view controller.
I have tried to use a custom gesture with a TapGesture() on onEnded() but the same result.
I also tried to add a Rectangle shape like said [here][1].
struct PlayerView: View {
#ObservedObject private var playerState = PlayerState()
#Binding var isPlayerReduced: Bool
private let interfaceColor: Color = .gray//.black
private let interfaceOpacity: Double = 0.9
private let interfaceAnimationDuration: Double = 0.4
var body: some View {
ZStack(content: {
GeometryReader(content: { geometry in
VStack(content: {
if !self.playerState.isInterfaceHidden {
TopPlayerView(playerState: self.playerState,
isPlayerReduced: self.$isPlayerReduced)
.transition(.opacity)
.background(self.interfaceColor.opacity(self.interfaceOpacity))
}
MiddlePlayerView(skipIntro: self.$playerState.skipIntro)
// Allow to spread the background zone for click purposes
.background(Color.clear)
// I want to have the middle under my TopPlayer and my BottomPlayer
.zIndex(-1)
.onTapGesture(perform: {
withAnimation(.easeInOut(duration: self.interfaceAnimationDuration)) {
self.playerState.isInterfaceHidden.toggle()
}
})
// .gesture(TapGesture()
// .onEnded({ _ in
// }))
if !self.playerState.isInterfaceHidden {
BottomPlayerView(playerState: self.playerState)
.padding(.bottom, geometry.safeAreaInsets.bottom)
.transition(.opacity)
.background(self.interfaceColor.opacity(self.interfaceOpacity))
}
})
})
})
.background(Color.black)
.edgesIgnoringSafeArea(.all)
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
I am kind of out of ideas here, any help is welcomed! thank you!
Alright, so after touching everything possible in this code. I ended up making it work.
The difference is where I put the padding to my views.
I switch the paddings to the VStack instead of my views in the VStack.
It seems to work now.
I post below the working code.
var body: some View {
ZStack(alignment: .center, content: {
GeometryReader(content: { geometry in
VStack(content: {
self.topMarker()
if !self.playerState.isInterfaceHidden {
TopPlayerView(playerState: self.playerState,
isPlayerReduced: self.$isPlayerReduced)
.transition(.opacity)
.background(self.interfaceColor.opacity(self.interfaceOpacity))
}
MiddlePlayerView(skipIntro: self.$playerState.skipIntro)
// Allow to spread the background zone for click purposes
.background(Color.white.opacity(0.00000001))
// I want to have the middle under my TopPlayer and my BottomPlayer
.zIndex(-1)
.onTapGesture(perform: {
withAnimation(.easeInOut(duration: self.interfaceAnimationDuration)) {
self.playerState.isInterfaceHidden.toggle()
}
})
if !self.playerState.isInterfaceHidden {
BottomPlayerView(playerState: self.playerState)
.transition(.opacity)
.background(self.interfaceColor.opacity(self.interfaceOpacity))
}
})
.padding(.top, 8)
.padding(.bottom, geometry.safeAreaInsets.bottom)
})
})
.background(Color.black)
.edgesIgnoringSafeArea(.all)
.navigationBarTitle("")
.navigationBarHidden(true)
}
To be honest, I have no idea what would be the difference here...
Even in the view debugger there is no difference..

ButtonAction overwrites preceding ButtonActions

I have an Imageview that has six buttons on top of it. Depending on which button is being clicked, I want to change the image. However, somehow the ButtonActions don't work as expected and I wasn't able to figure out how to change the behavior correctly.
The ZStack for the Overlay looks like that:
class ImageName: ObservableObject {
#Published var name: String = "muscleGuy"
}
struct MuscleGuy: View {
#ObservedObject var imageName = ImageName()
var body: some View {
VStack(alignment: .center) {
Spacer()
ZStack(alignment: .center) {
GeometryReader { geometry in
//the buttons, overlaying the image
ButtonActions( imageName: self.imageName).zIndex(10)
//the image
ImageView(imageName: self.imageName, size: geometry.size).zIndex(2)
.position(x: geometry.size.width/2, y: geometry.size.height/2)
}
}
}
}
}
And to aggregate the different buttons, I created the ButtonActions-struct:
struct ButtonActions: View {
#State var imageName: ImageName
var body: some View {
VStack{
GeometryReader { geometry in
HStack {
Button(action: {
self.imageName.name = "arms_chest"
print(self.imageName.name)
}) {
armsRectangle(size: geometry.size)
}
Button(action: {
self.imageName.name = "abs_lower_back"
print(self.imageName.name)
}) {
absRectangle(size: geometry.size)
}
}
Button(action: {
self.imageName.name = "legs"
print(self.imageName.name)
}) {
legRectangle(size: geometry.size)
}
}
}
}
}
The legRectangle contains one rectangle only, whereas the armsRectangle contains three Rectangles and the absRectangle contains two. They are always nested in a HStack.
I don't want to overload my question with code, so here is one example Rectangle:
struct absRectangle: View {
var size: CGSize
var body: some View {
HStack {
Rectangle().foregroundColor(.red)
.frame(width: size.width/8 + 8, height: size.height/9)
.position(x: -size.width/4, y: size.height/2 - 40)
Rectangle().foregroundColor(.red)
.frame(width: size.width/8 + 8, height: size.height/9)
.position(x: -10, y: size.height/2 - 40)
}
}
}
The behavior that occurs is that the legRectangle always takes over the whole screen. So wherever I tab, the legRectangle-action is being executed.
When commenting that section out and assigning the same zIndex to both the armsRectangle and the absRectangle, it recognizes different tap actions. But the behavior isn't correct either. For example only the right ab-rectangle-action is triggered and the left one again also leads to the arms-rectangle-action.
I feel like the behavior has something to do with the alignment within the HStacks/VStacks/ZStack but I can't figure out what exactly is wrong.

SwiftUI View struct without reloading

I would like to create a starry background view in SwiftUI that has its stars located randomly using Double.random(), but does not reinitialise them and move them when the parent view reloads its var body.
struct ContentView: View {
#State private var showButton = true
var body: some View {
ZStack {
BackgroundView()
if showButton {
Button("Tap me"){
self.showButton = false
}
}
}
}
}
I define my background view as such.
struct BackgroundView: View {
var body: some View {
ZStack {
GeometryReader { geometry in
Color.black
ForEach(0..<self.getStarAmount(using: geometry), id: \.self){ _ in
Star(using: geometry)
}
LinearGradient(gradient: Gradient(colors: [.purple, .clear]), startPoint: .bottom, endPoint: .top)
.opacity(0.7)
}
}
}
func getStarAmount(using geometry: GeometryProxy) -> Int {
return Int(geometry.size.width*geometry.size.height/100)
}
}
A Star is defined as
struct Star: View {
let pos: CGPoint
#State private var opacity = Double.random(in: 0.05..<0.4)
init(using geometry: GeometryProxy) {
self.pos = CGPoint(x: Double.random(in: 0..<Double(geometry.size.width)), y: Double.random(in: 0..<Double(geometry.size.height)))
}
var body: some View {
Circle()
.foregroundColor(.white)
.frame(width: 2, height: 2)
.scaleEffect(CGFloat(Double.random(in: 0.25...1)))
.position(pos)
.opacity(self.opacity)
.onAppear(){
withAnimation(Animation.linear(duration: 2).delay(Double.random(in: 0..<6)).repeatForever()){
self.opacity = self.opacity+0.5
}
}
}
}
As one can see, a Star heavily relies on random values, for both its animation (to create a 'random' twinkling effect) as well as its position. When the parent view of the BackgroundView, ContentView in this example, gets redrawn however, all Stars get reinitialised, their position values change and they move across the screen. How can this best be prevented?
I have tried several approaches to prevent the positions from being reinitialised. I can create a struct StarCollection as a static let of BackgroundView, but this is quite cumbersome. What is the best way to go about having a View dependent on random values (positions), only determine those positions once?
Furthermore, the rendering is quite slow. I have attempted to call .drawingGroup() on the ForEach, but this then seems to interfere with the animation's opacity interpolation. Is there any viable way to speed up the creation / re-rendering of a view with many Circle() elements?
The slowness coming out from the overcomplicated animations setting in onAppear, you only need the self.opacity state change to initiate the animation, so please move animation out and add to the shape directly.
Circle()
.foregroundColor(.white)
.frame(width: 2, height: 2)
.scaleEffect(CGFloat(Double.random(in: 0.25...1)))
.position(pos)
.opacity(self.opacity)
.animation(Animation.linear(duration: 0.2).delay(Double.random(in: 0..<6)).repeatForever())
.onAppear(){
// withAnimation{ //(Animation.linear(duration: 2).delay(Double.random(in: 0..<6)).repeatForever()){
self.opacity = self.opacity+0.5
// }
}