How can I create an animation color change from center of a view in SwiftUI? - swift

How can I create an animation color change from center of a view in SwiftUI? (please see the below gif)?
So far I have tried adding an animation modifier to the view (please see the below code), but am not quite sure how to make the animation start at the center of the screen and flow to the outer edges of the screen.
import SwiftUI
struct ContentView: View {
#State var color = Color.red
var body: some View {
VStack {
Button(action: {
color = .green
}, label: {
Text("change background color")
})
}
.frame(maxWidth: .infinity)
.frame(maxHeight: .infinity)
.background(color)
.animation(.default)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

You can use two Circles in a ZStack and animate the scale effect value of the top most circle.
struct ContentView: View {
private let colors: [Color] = [.red, .yellow, .blue, .pink, .orange]
private let maxScaleEffect: CGFloat = 4.0
private let minScaleEffect: CGFloat = 0
private let animationDuration = 0.6
private let animationDelay = 0.1
#State private var shouldTransition = true
#State private var colorIndex = 0
var body: some View {
ZStack {
Circle()
.fill(previousColor)
.scaleEffect(maxScaleEffect)
Circle()
.fill(transitioningColor)
.scaleEffect(shouldTransition ? maxScaleEffect : minScaleEffect)
Button("Change color") {
shouldTransition = false
colorIndex += 1
// Removing DispatchQueue here will cause the first transition not to work
DispatchQueue.main.asyncAfter(deadline: .now() + animationDelay) {
withAnimation(.easeInOut(duration: animationDuration)) {
shouldTransition = true
}
}
}
.foregroundColor(.primary)
}
}
private var previousColor: Color {
colors[colorIndex%colors.count]
}
private var transitioningColor: Color {
colors[(colorIndex+1)%colors.count]
}
}

Related

Animate view every time an Observed property changes in SwiftUI

In the following code, an Image is animated when you tap on it by changing the #State property value. What I would like to be able to do is animate the image every time a #Published property value changes, this property is located inside an ObservableObject and is dynamically changing.
Using Local #State property wrappers, works fine.
struct ContentView: View {
#State private var scaleValue = 1.0
#State private var isAnimating: Bool = true
var body: some View {
Image(systemName: "circle.fill")
.scaleEffect(scaleValue)
.font(.title)
.foregroundColor(.green)
.animation(.easeOut(duration: 0.3), value: isAnimating)
.onTapGesture {
self.isAnimating.toggle()
self.scaleValue = 1.5
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3){
self.scaleValue = 1.0
}
}
}
}
Using Observed #Published property wrappers, not working. Not sure where to place the animation.
struct ContentView: View {
#State private var scaleValue = 1.0
#StateObject private var contentVM = ContentViewModel()
var body: some View {
Image(systemName: "circle.fill")
.scaleEffect(scaleValue)
.font(.title)
.foregroundColor(.green)
.animation(.easeOut(duration: 0.3), value: contentVM.isAnimating)
// not sure where to add the animation
}
}
EDIT: Here is the Working Solution:
Thanks to #Fogmeister and #burnsi
class ContentViewModel: ObservableObject{
#Published var isAnimatingImage = false
}
struct ContentView2: View {
#StateObject private var contentVM = ContentViewModel()
var body: some View {
#State private var scaleValue = 1.0
Image(systemName: "circle.fill")
.scaleEffect(scaleValue)
.font(.title)
.foregroundColor(.green)
.animation(.easeOut(duration: 0.3), value: scaleValue)
.onChange(of: contentVM.isAnimatingImage) {newValue in
animateImage()
}
}
func animateImage(){
scaleValue = 1.5
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3){
scaleValue = 1.0
}
}
}
I think the issue here is the wrong usage of the .animation modifier.
You don´t need to trigger the animation with a different boolean value. If the animation is "driven" by changing a value, in this case scaleValue, use that value in the modifier.
Take a look at your animation in your first example. It doesn´t complete. It scales to the desired size and then shrinks. But it jumps in the middle of the animation while shrinking back.
This would be a proper implementation in your first example:
struct ContentView: View {
#State private var scaleValue = 1.0
var body: some View {
Image(systemName: "circle.fill")
.scaleEffect(scaleValue)
.font(.title)
.foregroundColor(.green)
.animation(.easeOut(duration: 0.3), value: scaleValue)
.onTapGesture {
self.scaleValue = 1.5
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3){
self.scaleValue = 1.0
}
}
}
}
and in your second example:
class ContentViewModel: ObservableObject{
#Published var scaleValue = 1.0
}
struct ContentView2: View {
#StateObject private var contentVM = ContentViewModel()
var body: some View {
Image(systemName: "circle.fill")
.scaleEffect(contentVM.scaleValue)
.font(.title)
.foregroundColor(.green)
.animation(.easeOut(duration: 0.3), value: contentVM.scaleValue)
.onTapGesture {
contentVM.scaleValue = 1.5
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3){
contentVM.scaleValue = 1.0
}
}
}
}
This is why we shouldn't be using view model objects in SwiftUI and this is not what #StateObject is designed for - it's designed for when we need a reference type in an #State which is not the case here. If you want to group your view data vars together you can put them in a struct, e.g.
struct ContentViewConfig {
var isAnimating = false
var scaleValue = 1.0
// mutating func someLogic() {}
}
// then in the View struct:
#State var config = ContentViewConfig()

Share background across views

I have two issues that would love some help on. I have a landing page, split up between two views. One view shows an image, and another displays two buttons (login and sign up). When navigating to the buttons, I would like to stay on the view and not navigate fully to a new view.
I would also like the background to remain the same as we navigate. I attached a gif of the desired effect (minus the background).
In the code below, I added a ZStack which adds a background modifier, but navigating goes fully to a new view, not remaining on the same page.
extension View {
func animatableGradient(fromGradient: Gradient, toGradient: Gradient, progress: CGFloat) -> some View {
self.modifier(AnimatableGradientModifier(fromGradient: fromGradient, toGradient: toGradient, progress: progress))
}
}
struct AnimatableGradientModifier: AnimatableModifier {
let fromGradient: Gradient
let toGradient: Gradient
var progress: CGFloat = 0.0 //keeps track of gradient change
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
var gradientColors = [Color]()
for i in 0..<fromGradient.stops.count {
let fromColor = UIColor(fromGradient.stops[i].color)
let toColor = UIColor(toGradient.stops[i].color)
gradientColors.append(colorMixer(fromColor: fromColor, toColor: toColor, progress: progress))
}
return LinearGradient(gradient: Gradient(colors: gradientColors), startPoint: .topLeading, endPoint: .bottomTrailing)
}
func colorMixer(fromColor: UIColor, toColor: UIColor, progress: CGFloat) -> Color {
guard let fromColor = fromColor.cgColor.components else { return Color(fromColor) }
guard let toColor = toColor.cgColor.components else { return Color(toColor) }
let red = fromColor[0] + (toColor[0] - fromColor[0]) * progress
let green = fromColor[1] + (toColor[1] - fromColor[1]) * progress
let blue = fromColor[2] + (toColor[2] - fromColor[2]) * progress
return Color(red: Double(red), green: Double(green), blue: Double(blue))
}
}
struct LandingPage: View {
#AppStorage("signedIn") var signedIn = false
#State private var isPressed = false
#Environment (\.dismiss) var dismiss
#State private var progress: CGFloat = 0
//colors for background on landing page
let gradient1 = Gradient(colors: [.yellow, .orange])
let gradient2 = Gradient(colors: [.yellow, .pink])
#State private var animateGradient = false
#State var scale: CGFloat = 1.0
#State var offsetValue: CGFloat = -60 // << image
#State var isMealJournalTitleShowing = false
#ViewBuilder
var body: some View {
NavigationView{
ZStack{
Rectangle()
.animatableGradient(fromGradient: gradient1, toGradient: gradient2, progress: progress)
.ignoresSafeArea()
VStack{
test()
VStack{
NavigationLink(destination: testb() .navigationBarHidden(true),
label:{
Text("Get Started").fontWeight(.bold)
.frame(minWidth: 0, maxWidth: 200)
.padding(10)
.foregroundColor(.white)
//draw rectange around buttons
.background(
RoundedRectangle(cornerRadius: 20)
.fill(
LinearGradient(
colors: [.orange, .yellow],
startPoint: .topLeading,
endPoint: .bottomTrailing
)))
})
.simultaneousGesture(TapGesture().onEnded{
offsetValue = -125
scale -= 0.50
isMealJournalTitleShowing = true
})
NavigationLink(destination: testb().navigationBarHidden(true), label: {
Text("Login").fontWeight(.semibold)
.frame(minWidth:0, maxWidth: 200)
.padding(10)
.foregroundColor(.black)
.overlay( RoundedRectangle(cornerRadius: 25)
.stroke(Color.gray, lineWidth: 3)
)
})
}
.frame(height: 500)
}
.frame(height: 500)
}
.onAppear {
DispatchQueue.main.async {
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: true)) {
self.progress = 1.0
}
}
}
.animation(.easeInOut(duration: 0.50), value: offsetValue)
}
}
}
Navigating creates and shows a complete new view, that's why the background changes, of course. Instead of navigating, maybe you could use something like this?
You could play around with transitions like .transition(.slide.combined(with: .opacity)) for example to get the effect you want.
To get the image animating and scaling, you could just lookup .matchedGeometryEffect.
import SwiftUI
enum ViewState {
case landingPage
case login
case register
case isLoggedIn
}
struct ContentView: View {
#State private var viewState: ViewState = .landingPage
var body: some View {
ZStack {
Color.teal.opacity(0.4)
switch viewState {
case .landingPage:
VStack {
Text("LandingPage")
.transition(.slide)
.padding(50)
Button("Login") {
viewState = .login
}
Button("Register") {
viewState = .register
}
}
case .login:
Text("Login")
.transition(.slide)
case .register:
Text("Register")
.transition(.slide)
case .isLoggedIn:
Text("LoggedIn")
.transition(.slide)
}
}
.animation(.default, value: viewState)
}
}

swiftUI geometryEffect animation

I'm practicing swiftui geometryEffect by applying it to a transition from view to another. The first view has three circles with different colors, the user selects a color by tapping the desired color, and clicks "next" to go to second view which contains a circle with the selected color.
GeometryEffect works fine when transitioning from FirstView to SecondView in which the selected circle's positions animates smoothly, but going back is the problem.
GeometryEffect does not animate the circle position smoothly when going back from SecondView to FirstView. Instead, it seems like it moves the circle to the left and right before positioning the circle to its original position.
Im sharing a GIF on my google drive to show what I mean:
I'd like to achieve something like this: desired result
(file size is too large to be uploaded directly)
Thank you!
FirstView:
struct FirstView: View {
#Namespace var namespace
#State var whichStep: Int = 1
#State var selection: Int = 0
var colors: [Color] = [.red, .green, .blue]
#State var selectedColor = Color.black
var transitionNext: AnyTransition = .asymmetric(
insertion: .move(edge: .trailing),
removal:.move(edge: .leading))
var transitionBack: AnyTransition = .asymmetric(
insertion: .move(edge: .leading),
removal:.move(edge: .trailing))
#State var transition: AnyTransition = .asymmetric(
insertion: .move(edge: .trailing),
removal:.move(edge: .leading))
var body: some View {
VStack {
HStack { // back and next step buttons
Button { // back button
print("go back")
withAnimation(.spring()) {
whichStep = 1
transition = transitionBack
}
} label: {
Image(systemName: "arrow.backward")
.font(.system(size: 20))
}
.padding()
Spacer()
Button { // next button
withAnimation(.spring()) {
whichStep = 2
transition = transitionNext
}
} label: {
Text("next step")
}
}
Spacer()
if whichStep == 1 {
ScrollView {
ForEach(0..<colors.count, id:\.self) { color in
withAnimation(.none) {
Circle()
.fill(colors[color])
.frame(width: 100, height: 100)
.matchedGeometryEffect(id: color, in: namespace)
.onTapGesture {
selectedColor = colors[color]
selection = color
}
}
}
}
Spacer()
} else if whichStep == 2 {
// withAnimation(.spring()) {
ThirdView(showScreen: $whichStep, namespace: namespace, selection: $selection, color: selectedColor)
.transition(transition)
// }
Spacer()
}
}
.padding()
}
}
SecondView:
struct ThirdView: View {
var namespace: Namespace.ID
#Binding var selection: Int
#State var color: Color
var body: some View {
VStack {
GeometryReader { geo in
Circle()
.fill(color)
.frame(width: 100, height: 100)
.matchedGeometryEffect(id: selection, in: namespace)
}
}
.ignoresSafeArea()
}
}

Why does the edgesIgnoringSafeArea modifier get applied after a transition ends in SwiftUI?

I want to overlay the UIScreen with an opacity transition. This is my view:
struct ContentView: View {
#State private var overlayUIScreen: Bool = false
var body: some View {
ZStack {
if overlayUIScreen {
Rectangle()
.edgesIgnoringSafeArea(.top)
.frame(width: UIScreen.main.bounds.size.width,
height: UIScreen.main.bounds.size.height)
.foregroundColor(Color.gray)
.transition(.opacity)
}
Button("Overlay?") {
withAnimation {
self.overlayUIScreen.toggle()
}
}
}
}
}
For some reason the Safe Area changes color after the transition is already finished.
Why does this happen and what can I do to fix this behavior?
Another solution would be to move the frame to modify the ZStack instead of the Rectangle
struct ContentView: View {
#State private var overlayUIScreen: Bool = false
var body: some View {
ZStack {
if overlayUIScreen {
Rectangle()
.edgesIgnoringSafeArea(.top)
.foregroundColor(Color.gray)
.transition(.opacity)
}
Button("Overlay?") {
withAnimation {
self.overlayUIScreen.toggle()
}
}
}
.frame(width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height)
}
}
A workaround would be to already have your view in the hierarchy and attribute it's opacity to a #State private var like so:
struct ContentView: View {
#State private var overlayOpacity: Double = 0.0
var body: some View {
ZStack {
Rectangle()
.edgesIgnoringSafeArea(.top)
.frame(width: UIScreen.main.bounds.size.width,
height: UIScreen.main.bounds.size.height)
.opacity(overlayOpacity)
.foregroundColor(Color.gray)
Button("Overlay?") {
withAnimation {
if self.overlayOpacity == 0.0 {
self.overlayOpacity = 1.0
} else {
self.overlayOpacity = 0.0
}
}
}
} .transition(.opacity)
}
}

Animate view on property change SwiftUI

I have a view
struct CellView: View {
#Binding var color: Int
#State var padding : Length = 10
let colors = [Color.yellow, Color.red, Color.blue, Color.green]
var body: some View {
colors[color]
.cornerRadius(20)
.padding(padding)
.animation(.spring())
}
}
And I want it to have padding animation when property color changes. I want to animate padding from 10 to 0.
I've tried to use onAppear
...onAppear {
self.padding = 0
}
But it work only once when view appears(as intended), and I want to do this each time when property color changes. Basically, each time color property changes, I want to animate padding from 10 to 0. Could you please tell if there is a way to do this?
As you noticed in the other answer, you cannot update state from within body. You also cannot use didSet on a #Binding (at least as of Beta 4) the way you can with #State.
The best solution I could come up with was to use a BindableObject and sink/onReceive in order to update padding on each color change. I also needed to add a delay in order for the padding animation to finish.
class IndexBinding: BindableObject {
let willChange = PassthroughSubject<Void, Never>()
var index: Int = 0 {
didSet {
self.willChange.send()
}
}
}
struct ParentView: View {
#State var index = IndexBinding()
var body: some View {
CellView(index: self.index)
.gesture(TapGesture().onEnded { _ in
self.index.index += 1
})
}
}
struct CellView: View {
#ObjectBinding var index: IndexBinding
#State private var padding: CGFloat = 0.0
var body: some View {
Color.red
.cornerRadius(20.0)
.padding(self.padding + 20.0)
.animation(.spring())
.onReceive(self.index.willChange) {
self.padding = 10.0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
self.padding = 0.0
}
}
}
}
This example doesn't animate in the Xcode canvas on Beta 4. Run it on the simulator or a device.
As of Xcode 12, Swift 5
One way to achieve the desired outcome could be to move the currently selected index into an ObservableObject.
final class CellViewModel: ObservableObject {
#Published var index: Int
init(index: Int = 0) {
self.index = index
}
}
Your CellView can then react to this change in index using the .onReceive(_:) modifier; accessing the Publisher provided by the #Published property wrapper using the $ prefix.
You can then use the closure provided by this modifier to update the padding and animate the change.
struct CellView: View {
#ObservedObject var viewModel: CellViewModel
#State private var padding : CGFloat = 10
let colors: [Color] = [.yellow, .red, .blue, .green]
var body: some View {
colors[viewModel.index]
.cornerRadius(20)
.padding(padding)
.onReceive(viewModel.$index) { _ in
padding = 10
withAnimation(.spring()) {
padding = 0
}
}
}
}
And here's an example parent view for demonstration:
struct ParentView: View {
let viewModel: CellViewModel
var body: some View {
VStack {
CellView(viewModel: viewModel)
.frame(width: 200, height: 200)
HStack {
ForEach(0..<4) { i in
Button(action: { viewModel.index = i }) {
Text("\(i)")
.padding()
.frame(maxWidth: .infinity)
.background(Color(.secondarySystemFill))
}
}
}
}
}
}
Note that the Parent does not need its viewModel property to be #ObservedObject here.
You could use Computed Properties to get this working. The code below is an example how it could be done.
import SwiftUI
struct ColorChanges: View {
#State var color: Float = 0
var body: some View {
VStack {
Slider(value: $color, from: 0, through: 3, by: 1)
CellView(color: Int(color))
}
}
}
struct CellView: View {
var color: Int
#State var colorOld: Int = 0
var padding: CGFloat {
if color != colorOld {
colorOld = color
return 40
} else {
return 0
}
}
let colors = [Color.yellow, Color.red, Color.blue, Color.green]
var body: some View {
colors[color]
.cornerRadius(20)
.padding(padding)
.animation(.spring())
}
}
Whenever there is a single incremental change in the color property this will toggle the padding between 10 and 0
padding = color % 2 == 0 ? 10 : 0