Can the changes of the ".disabled" modifier of SwiftUI be animated? - swift

I think that the View.disabled(_:) modifier changes the .foregroundColor of a View (besides enabling / disabling interactions with it). Could the color change be animated?
I know it's pretty easy to animate say .opacity changes with implicit animations (e.g.: .animation(.default)). However the same won't work with .disabled.
Please consider the following code:
import SwiftUI
struct SomeButton: View {
#State var isDisabled = false
#State var isHidden = false
var body: some View {
VStack(spacing: 30) {
Spacer()
Button(action: {}) {
Image(systemName: "pencil.circle.fill")
.resizable()
}
.frame(width: 80, height: 80)
.disabled(isDisabled) // 👈🏻 Will not animate at all.
.opacity(isHidden ? 0 : 1) // 👈🏻 Will animate just fine.
.animation(.default) // 👈🏻 Set to use implicit animations.
VStack(spacing: 10) {
Button(action: {
self.isHidden.toggle()
}) {
Text("Hide")
}
Button(action: {
self.isDisabled.toggle()
}) {
Text("Disable")
}
}
Spacer()
}
}
}
// MARK: - Preview
#if DEBUG
struct SomeButton_Previews: PreviewProvider {
static var previews: some View {
SomeButton()
}
}
#endif
This produces the following result, which presents a smooth opacity transition:
... on the other hand the enabled / disabled color transition is binary. It doesn't look that good.
My current workaround is to change the opacity of a view based on its enabled/disabled state.
So for example if a view is disabled, the opacity is 0.6. If enabled, the opacity goes back to 1.0.
But it feels wrong. There must be another way.

The possible solution can be to combine with .colorMultipy, of course the disabled color should be experimentally fit, but this gives common effect animatable
.disabled(isDisabled)
.colorMultiply(isDisabled ? // 👈🏻 animatable
Color.gray /* to be selected to fit*/ : .white)
.animation(.default, value: isDisabled)

Related

SwiftUI translate and rotate view with matchedGeometryEffect strange behavior

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.

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..

How to keep the animation running on TabView View change?

I've recently found that when an animation is running forever and the View is switched through the TabView, the animation state is lost. While this is expected behaviour, I'd like to understand if there is a way to keep the animation running when back to the animated View, after the TabView View change?
Here's a quick example, showing the behaviour in SwiftUI:
import SwiftUI
class MyState: ObservableObject {
#Published var animate: Bool = false
}
struct ContentView: View {
var body: some View {
TabView {
AnimationView()
.tabItem {
Image(systemName: "eye")
}
Text("Some View")
.tabItem {
Image(systemName: "play")
}
}
}
}
struct AnimationView: View {
#EnvironmentObject var myState: MyState
var body: some View {
VStack {
Text(self.myState.animate ? "Animation running" : "Animation stopped")
.padding()
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 12.0))
.foregroundColor(Color.black)
.opacity(0.3)
Circle()
.trim(
from: 0,
to: self.myState.animate ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round)
)
.animation(
Animation
.linear(duration: self.myState.animate ? 1.0 : 0.0)
.repeatForever(autoreverses: false)
)
}.frame(width: 200, height: 200)
Button(action: {
self.myState.animate.toggle()
}) {
Text("Toggle animation")
.padding()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(MyState())
}
}
Below you can see the behaviour:
What I'd expect to control, is get back to the View where the animation runs and see the state it'd be at the current time - this is, imagining that the animation is looping when switching Views and back to the View find it in the correct point in "running time" or "real-time".
Obs: The State is handled in MyState an EnvironmentObject and it's not local (#State) in the View
So I added an extra parameter to your MyState to store the current animation status when the user switches between tabs. Below is the updated code. I have also added comments in the code for better understanding.
class MyState: ObservableObject {
#Published var animate: Bool = false
var isAnimationActive = false // Store animate status when user switches between tabs
}
struct AnimationView: View {
#EnvironmentObject var myState: MyState
var body: some View {
VStack {
Text(self.myState.animate ? "Animation running" : "Animation stopped")
.padding()
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 12.0))
.foregroundColor(Color.black)
.opacity(0.3)
Circle()
.trim(
from: 0,
to: self.myState.animate ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round)
)
.animation(
Animation
.linear(duration: self.myState.animate ? 1.0 : 0.0)
.repeatForever(autoreverses: false)
)
}.frame(width: 200, height: 200)
Button(action: {
self.myState.animate.toggle()
}) {
Text("Toggle animation")
.padding()
}
}
.onAppear(perform: {
if self.myState.isAnimationActive {
/*switching the animation inside withAnimation block is important*/
withAnimation {
self.myState.animate = true
}
}
})
.onDisappear {
self.myState.isAnimationActive = self.myState.animate
/*The property needs to be set to false as SwiftUI compare the
views before re-rendering them*/
self.myState.animate = false
}
}
}
After some tests, the best solution I found this far is to follow a few rules I've stated below. Have in mind that this is only useful if you can track the exact animation position in time. So please check the process bellow:
1) Stop the animation .onDisappear
The reason is that state changes between views that have animated elements will cause unexpected behaviour, for example, the element positioning be completely off and so on.
self.myState.animate = false
2) Keep track of progress
My use-case is a long computation that starts from 0 and ends in X and MyState has methods that provide me with the current position in the computation. This value is then is calculated in the range* 0 to 1, so that it's possible to put it into the SwiftUI Animation.
3) Jumpstart to a specific time
This part is not supported yet in SwiftUI Animation, but let's say that you have an animation that takes 5 seconds, you're at 50% playtime, so you'd jump to 2.5 seconds where you'd want to start play.
An implementation for this, if you've experienced Javascript and way before, ActionScript is GSAP doc for seek (https://greensock.com/docs/v2/Core/Animation/seek())
4) Re-trigger the animation
We started by stopping the animation by changing the state, to make SwiftUI start the animation all we need to do at this stage is to change the state once again
self.myState.animate = true
There might be other ways to keep the animation running, but at the time of writing haven't found it documented, so there was a need to "start animation from any point".