I have a problem with an animation that involves a Text. Basically I need to change the text and animate its position. Take a look at this simple example here below:
import SwiftUI
struct ContentView: View {
#State private var isOn = false
#State private var percentage = 100
var body: some View {
ZStack {
Text("\(percentage)")
.font(.title)
.background(Color.red)
.position(isOn ? .init(x: 150, y: 300) : .init(x: 150, y: 100))
.animation(.easeOut(duration: 1), value: isOn)
VStack {
Spacer()
Button(action: {
self.isOn.toggle()
//just to make the issue happen
if self.percentage == 100 {
self.percentage = 0
} else {
self.percentage = 100
}
}, label: {
Text("SWITCH")
})
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The result is:
There are some issues here. Probably the most annoying is the glitch with the ... I just want to animate the position of the Text, I don't want to animate the text itself and I don't want to animate the width of the text. Any ideas? Thank you.
The possible alternate is to add scaling factor, it supersedes truncation mode and gives different effect, which under some circumstances might be preferable.
The only needed changes is as below (factor modifiable of course)
Text("\(percentage)")
.minimumScaleFactor(0.5)
Try this action on the button:
Button(action:
{
//just to make the issue happen
if self.percentage == 100
{
self.percentage = 0
}
else
{
self.percentage = 100
}
withAnimation(.easeInOut(duration: 1.0)) {
self.isOn.toggle()
}
}, label: {
Text("SWITCH")
})
And remove that line from your Label
.animation(.easeOut(duration: 1), value: isOn)
I didn't test it yet.
Related
I have a view that should scale in and out, starting immediately when the view is shown and repeating forever. However, I find that it's actually animating up and down as well as scaling much like in this post when it's pushed from a navigation view:
struct PlaceholderView: View {
#State private var isAnimating = false
var body: some View {
Circle()
.frame(width: 30, height: 30)
.scaleEffect(self.isAnimating ? 0.8 : 1)
.animation(Animation.easeInOut(duration: 1).repeatForever())
.onAppear {
self.isAnimating = true
}
.frame(width: 50, height: 50)
.contentShape(
Rectangle()
)
}
}
struct SettingsView: View {
#State private var showPlaceholder = false
var body: some View {
NavigationView {
ZStack {
Button(
action: {
showPlaceholder = true
}, label: {
Text("Go to placeholder")
}
)
NavigationLink(
destination: PlaceholderView(),
isActive: $showPlaceholder
) {
EmptyView()
}
.hidden()
}
}
.navigationViewStyle(.stack)
}
}
Why is this and how can I stop this from happening?
UPDATE:
Wrapping self.isAnimating = true in DispatchQueue.main.async {} fixes the issue, but I don't understand why...
I can reproduce the problem, but I wasn't able to reproduce your DispatchQueue.main.async {} fix.
Here is how I fixed it:
Animation is made by comparing a before state and and after state and animating between them. With an implicit animation, the before state is the position of the circle before it is added to the navigation view. By switching to an explicit animation and setting your before state after the view appears, the move animation is avoided and you only get the scaling:
Replace isAnimating with scale. In onAppear {}, first establish the before scale of 0.8, then animate the change to scale 1:
struct PlaceholderView: View {
// To work correctly, this initial value should be different
// than the before value set in .onAppear {}.
#State private var scale = 1.0
var body: some View {
Circle()
.frame(width: 30, height: 30)
.scaleEffect(self.scale)
.onAppear {
self.scale = 0.8 // before
withAnimation(.easeInOut(duration: 1).repeatForever()) {
self.scale = 1 // after
}
}
.frame(width: 50, height: 50)
.contentShape(
Rectangle()
)
}
}
Here's a main ContentView and DetailedCardView and when open DetailedCardView there's a ScrollView with more content. It also have closeGesture which allow to close the card when swiping from left to right and vice versa. So, at that point problems start to appear, like this ones inside DetailedCardView:
When scrolling in ScrollView then safeAreaInsets .top and .bottom start to be visible and as a result it's shrink the whole view vertically.
The closeGesture and standard ScrollView gesture while combined is not smooth and I'd like to add closing card not only when swiping horizontally but vertically too.
As a result I'd like to have the same gesture behaviour like opening and closing detailed card in App Store App Today section. Would love to get your help 🙌
ContentView DetailedCardView closeGesture in Action safeAreaInsets
struct ContentView: View {
#State private var showCardView = false
let colors: [Color] = [.red, .purple, .yellow, .green, .blue, .mint, .orange]
#State private var selectedCardNumber = 0
var body: some View {
ZStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(1..<101) { number in
colors[number % colors.count]
.overlay(Text("\(number)").font(.largeTitle.bold()).foregroundColor(.white))
.frame(height: 300)
.cornerRadius(30)
.padding(10)
.onTapGesture {
showCardView.toggle()
selectedCardNumber = number
}
}
}
}
.opacity(showCardView ? 0 : 1)
.animation(.spring(), value: showCardView)
if showCardView {
CardDetailView(showCardView: $showCardView, number: selectedCardNumber)
}
}
}
}
struct CardDetailView: View {
#Binding var showCardView: Bool
#State private var cardPosition: CGSize = .zero
let number: Int
var body: some View {
ScrollView {
RoundedRectangle(cornerRadius: 30, style: .continuous)
.fill(.green)
.overlay(Text("\(number)").font(.system(size: 100).bold()).foregroundColor(.white))
.frame(height: 500)
Color.red.opacity(0.8)
.frame(height: 200)
Color.gray.opacity(0.8)
.frame(height: 200)
Color.blue
.frame(height: 200)
Color.yellow
.frame(height: 200)
}
.scaleEffect(gestureScale())
.gesture(closeGesture)
}
private var closeGesture: some Gesture {
DragGesture()
.onChanged { progress in
withAnimation(.spring()) {
cardPosition = progress.translation
}
}
.onEnded { _ in
withAnimation(.spring()) {
let positionInTwoDirections = abs(cardPosition.width)
if positionInTwoDirections > 100 {
showCardView = false
}
cardPosition = .zero
}
}
}
private func gestureScale() -> CGFloat {
let max = UIScreen.main.bounds.width / 2
let currentAmount = abs(cardPosition.width)
let percentage = currentAmount / max
return 1 - min(percentage, 0.5)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
When I put an explicit animation inside a NavigationView, as an undesirable side effect, it animates the initial layout of the NavigationView content. It gets especially bad with infinite animations. Is there a way to disable this side effect?
Example: the image below is supposed to be an animated red loader on a full screen blue background. Instead I get this infinite loop of a scaling blue background:
import SwiftUI
struct EscapingAnimationTest: View {
var body: some View {
NavigationView {
VStack {
Spacer()
EscapingAnimationTest_Inner()
Spacer()
}
.backgroundFill(Color.blue)
}
}
}
struct EscapingAnimationTest_Inner: View {
#State var degrees: CGFloat = 0
var body: some View {
Circle()
.trim(from: 0.0, to: 0.3)
.stroke(Color.red, lineWidth: 5)
.rotationEffect(Angle(degrees: degrees))
.onAppear() {
withAnimation(Animation.linear(duration: 1).repeatForever(autoreverses: false)) {
degrees = 360
}
}
}
}
struct EscapingAnimationTest_Previews: PreviewProvider {
static var previews: some View {
EscapingAnimationTest()
}
}
Here is fixed part (another my answer with explanations is here).
Tested with Xcode 12 / iOS 14.
struct EscapingAnimationTest_Inner: View {
#State var degrees: CGFloat = 0
var body: some View {
Circle()
.trim(from: 0.0, to: 0.3)
.stroke(Color.red, lineWidth: 5)
.rotationEffect(Angle(degrees: Double(degrees)))
.animation(Animation.linear(duration: 1).repeatForever(autoreverses: false), value: degrees)
.onAppear() {
DispatchQueue.main.async { // << here !!
degrees = 360
}
}
}
}
Update: the same will be using withAnimation
.onAppear() {
DispatchQueue.main.async {
withAnimation(Animation.linear(duration: 1).repeatForever(autoreverses: false)) {
degrees = 360
}
}
}
Using DispatchQueue.main.async before outside of the withAnimation blocks worked for me but this code didn't look very clean.
I found another (and in my opinion cleaner) solution which is this:
Create a isAnimating variable outside of the body
#State var isAnimating = false
Then at the end of your outer VStack, set this variable to true inside onAppear. Then call rotationEffect with isAnimating ternary operator and then clal .animation() after. Here is the full code:
var body: some View {
VStack {
// the trick is to use .animation and some helper variables
Circle()
.trim(from: 0.0, to: 0.3)
.stroke(Color.red, lineWidth: 5)
.rotationEffect(Angle(degrees: isAnimating ? 360 : 0))
.animation(Animation.linear(duration:1).repeatForever(autoreverses: false), value: isAnimating)
} //: VStack
.onAppear {
isAnimating = true
}
}
This way you don't need to use DispatchQueue.main.async.
I have this LoaderView which grow with given value, I want add some highlight effect to it as well, So basically there is 2 deferent and separate animation happening, one for activeValue and one for the highlight. i did not combine them together because they are 2 deferent things, but my output of codes combine these 2 values together and destroys highlight effect. I am looking a solid answer for my issue to having this 2 animation works side by side.
The issue happens when i add value.
I want this view be usable in macOS as well, so there is a possibility user changes the Window size, then my view would be changed as well.
struct ContentView: View {
#State private var value: CGFloat = 50
var body: some View {
LoaderView(activeValue: value, totalValue: 100)
.padding()
Button("add") { value += 10 }
.padding()
}
}
struct LoaderView: View {
let activeValue: CGFloat
let totalValue: CGFloat
let activeColor: Color
let totalColor: Color
init(activeValue: CGFloat, totalValue: CGFloat, activeColor: Color = Color.green, totalColor: Color = Color.secondary) {
if activeValue >= totalValue { self.activeValue = totalValue }
else if activeValue < 0 { self.activeValue = 0 }
else { self.activeValue = activeValue }
self.totalValue = totalValue
self.activeColor = activeColor
self.totalColor = totalColor
}
#State private var startAnimation: Bool = .init()
private let heightOfCapsule: CGFloat = 5.0
var body: some View {
let linearGradient = LinearGradient(colors: [Color.yellow.opacity(0.1), Color.yellow], startPoint: .leading, endPoint: .trailing)
return GeometryReader { proxy in
totalColor
.overlay(
Capsule()
.fill(activeColor)
.frame(width: (activeValue / totalValue) * proxy.size.width)
.overlay(
Capsule()
.fill(linearGradient)
.frame(width: 100)
.offset(x: startAnimation ? 100 : -100),
alignment: startAnimation ? Alignment.trailing : Alignment.leading)
.clipShape(Capsule()),
alignment: Alignment.leading)
.clipShape(Capsule())
}
.frame(height: heightOfCapsule)
.onAppear(perform: { startAnimation.toggle() })
.animation(Animation.default, value: activeValue)
.animation(Animation.linear(duration: 3.0).repeatForever(autoreverses: false), value: startAnimation)
}
}
The issue isn't the .animations it is that .onAppear is only called once, so your animation never restarts. startAnimation needs to be toggled when activeValue changes. The way your animation works, toggling startAnimation reverses the animation direction. Therefore, I set autoreverses: true. It is a compromise either way.
struct LoaderView: View {
//Nothing changed in this portion
.onAppear{
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
startAnimation.toggle()
}
}
// The issue is that .onAppear() is only called once.
.onChange(of: activeValue, perform: { _ in
startAnimation.toggle()
})
.animation(Animation.default, value: activeValue)
// As a side effect, changing startAnimation to opposite values reverses the animation
// you have made, so setting autoreverses: true hides this.
.animation(Animation.linear(duration: 3.0).repeatForever(autoreverses: true), value: startAnimation)
}
}
Edit:
I apologize for not getting back to this sooner. The issue with the rubber band animation is because of the transition when the app firsts launches. To prevent this, you need to wrap your variable change in .onAppear() in a DispatchQueue.main.asyncAfter(deadline:) with enough time for the view to load. Then when you start the animation, it works correctly.
So I'm trying to mimic the Apple Podcasts app expanding audio player.
So far I've added an overlay to my TabView and it works like a charm, se below:
TabView {
...
}.overlay(
PlayerView()
)
Now I want to achieve the expanding view/sheet similar to the gif above for my PlayerView(), how would I go about doing that in SwiftUI?
So I solved this issue after some time researching, was a bit of hassle getting the animation working smoothly but I think I got it in the end. See code below:
App.swift
TabView {
...
}.overlay(
FloatingPlayer()
.edgesIgnoringSafeArea(.all)
)
FloatingPlayer.swift
struct FloatingPlayer: View {
#State var viewState = CGSize.zero
#State var playerExpanded = false
var body: some View {
GeometryReader { geometry in
VStack {
if self.playerExpanded {
Spacer()
}
ZStack {
VisualEffectView(effect: UIBlurEffect(style: self.settings.playerExpanded ? .systemThickMaterialDark : .dark))
.frame(
width: geometry.size.width,
height: self.playerExpanded ? geometry.size.height + 10 : 60
)
}
.offset(y: self.viewState.height)
.gesture(DragGesture()
.onChanged { value in
if (self.playerExpanded) {
self.viewState = value.translation
if (value.translation.height > 200) {
self.playerExpanded = false
self.simpleSuccess()
}
}
}
.onEnded { value in
self.viewState = CGSize.zero
})
.onTapGesture {
if !self.playerExpanded {
self.playerExpanded.toggle()
}
}
}
.animation(.interactiveSpring(response: 0.5, dampingFraction: 0.9, blendDuration: 0.3))
.statusBar(hidden: true)
}
}
}
You would also need to play with some paddings in order for it to work perfectly.