Let's make a simple custom "infinite" spinner.
SwiftUI provides ProgressView as the prototypical container for this with ProgressViewStyle as a means of customizing the way to present the progress.
To make our spinner infinitely spin, we'll use a linear animation, repeat it forever, start it at a rotation effect of 0 and move it to 360 degrees, as soon as the spinner appears.
Simple!
Now let's put it inside a NavigationView 🔥:
We see the spinner bouncing up and down as it spins. Any clue what's going on or how to address the issue (while preserving a respect for the fundamentals, eg. ProgressView to display progress and ProgressViewStyle to customize it)?
struct SimplePreview: PreviewProvider, View {
static var previews = Self()
#State
private var isAnimating = false
var body: some View {
NavigationView {
ProgressView().progressViewStyle(SymbolicStyle())
}
}
public struct SymbolicStyle: ProgressViewStyle {
#State
private var isAnimating = false
public func makeBody(configuration: Configuration) -> some View {
Image(systemName: "circle.hexagonpath.fill")
.rotationEffect(self.isAnimating ? .degrees(360) : .zero)
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
self.isAnimating = true
}
}
}
}
}
You can replace .onAppear with .task in order to start the animation in a separate context where it does not get mixed up with other unreleated layout events.
Related
I want to make my code inside a scrollview based on a boolean
Something like this :
ScrollView{
Vstack {
}
Hstack {
}
}.isWrapped(true)
So if the bool is true, the content is wrapped inside a ScrollView, if not, I just get the content as is. (without the scrollView parent)
I tried with multiples way, the only thing I see as possible is 2 blocks of code, not good practice thought.
One way is to always use a ScrollView, but tell it not to scroll. If you pass an empty set as the first argument to ScrollView, it won't scroll.
ScrollView(shouldScroll ? .vertical : []) {
// content here
}
Another way is to extract the content back out of the ScrollView in your proposed isWrapped modifier, like this:
extension ScrollView {
#ViewBuilder
func isWrapped(_ flag: Bool) -> some View {
if flag { self }
else { self.content }
}
}
Because of the nature of SwiftUI, you can't remove the parent without removing children. As you mentioned in your edit, this will require two blocks of code. But, you can make it much more friendly in two different ways.
The first way is a reusable component. It takes a Binding isVisible variable and the content.
struct ConditionalScrollView<Content: View>: View {
#Binding private var isVisible: Bool
private var builtContent: Content
init(isVisible: Binding<Bool>, content: () -> Content) {
self._isVisible = isVisible
builtContent = content()
}
var body: some View {
if isVisible {
ScrollView { builtContent }
} else {
builtContent
}
}
}
To use this new component is to simply replace the use of ScrollView in the area that you want to manually adjust. I.E:
struct ContentView: View {
#State var isVisible = true
var body: some View {
ConditionalScrollView(isVisible: $isVisible) {
Text("Hello, world!")
.padding()
}
}
}
But this is not your only option.
If you want to keep things as simple as possible, you can achieve this using a simple ViewBuilder. Create the view you don't want to change inside the ViewBuilder, and then create a condition around the ScrollView, all the while placing the variable as the content. It would look as follows:
struct ContentView: View {
#State private var isVisible = true
#ViewBuilder private var mainContent: some View {
Text("Hello, world!")
.padding()
}
var body: some View {
if isVisible {
ScrollView { mainContent }
} else {
mainContent
}
}
}
As you've probably come to realize, this is one of the limitations of SwiftUI. But it can be considered a strength because you will always know if a view's parent is there or not.
I hope this little tidbit helped, happy coding!
You can also make it even easier:
#State var isWraped : bool = true
var body: some View {
if isWrapped {
ScrollView {
YourView()
}
else {
YourView()
}
I have a VStack where I have a title above some other views. The title is shown/hidden based on the value of an #Published variable from an environment object. Ideally, when the title should be hidden, I want it to transition by fading out and moving the views below it in the VStack up. However, what actually happens is it just disappears immediately and moves the rest of the views up without animation. How can I fix this?
struct MyView: View {
#EnvironmentObject var modelController: MyModelController
var body: some View {
VStack {
title()
//..other views here
}
}
#ViewBuilder
func title() -> some View {
if let currentPage = modelController.currentPage,
currentPage >= 6 {
EmptyView()
} else {
Text("Create Event")
}
}
}
You'll need to use the .transition() modifier to tell the system that you want to animate when the view appears or disappears. Additionally, I don't think you need to return EmptyView when you want to hide the title.
#ViewBuilder
func title() -> some View {
if modelController.currentPage ?? 0 < 6 {
Text("Create Event")
.transition(.opacity)
}
}
I've used the opacity transition but it's a very customizable modifier, and you can pass an asymmetric transition that does different animations on insert and removal. I would suggest googling it or looking at the documentation to learn more about it.
Your code snapshot is not testable, but try the following
VStack {
title()
//..other views here
}
.animation(.default, value: modelController.currentPage) // << here !!
As the title described. In some case, I want stop the rolling List immediately.
That's easy with UIKit. But there seems no obvious way to do it in SwiftUI.
Just do UIScrollView.appearance().bounces = false.
struct ContentView: View {
init() {
UIScrollView.appearance().bounces = false /// here!
}
var body: some View {
ScrollView {
Rectangle()
.frame(height: 1000)
}
}
}
According to the Apple documentation we should be able to attach an animation directly to a transition. For example:
.transition(AnyTransition.slide.animation(.linear))
documentation for the method:
extension AnyTransition {
/// Attach an animation to this transition.
public func animation(_ animation: Animation?) -> AnyTransition
}
says:
Summary
Attaches an animation to this transition.
But I can't manage to make it work. Take a look at this minimum viable example (you can copy-paste it and try yourself):
import SwiftUI
struct AnimationTest: View {
#State private var show = false
var body: some View {
VStack {
if show {
Color.green
.transition(AnyTransition.slide.animation(.linear))
} else {
Color.yellow
.transition(AnyTransition.slide.animation(.linear))
}
Button(action: {
self.show.toggle()
}, label: {
Text("CHANGE VIEW!")
})
}
}
}
struct AnimationTest_Previews: PreviewProvider {
static var previews: some View {
AnimationTest()
}
}
As you can see no animation happens at all. Any ideas? Thank you.
You need to wrap the boolean toggling within a withAnimation() closure:
withAnimation {
self.show.toggle()
}
Tested the transition working on Xcode 11.2.1 while on the simulator; the Canvas doesn't preview it.
Please note that animations/transitions applied directly to a view have an effect on that particular view and its children. Moreover, according to the docs:
func animation(Animation?) -> View
Applies the given animation to all animatable values within the view.
Since the animatable value, in this case the Bool toggle enabling the transition, is external to the Color views, it must be animated explicitly from where it's set in the button's action. Alternatively, one can effectively attach the transition directly to the target views, but apply the animation to their container, thus enabling interpolation of changes to show. So, this also achieves the desired result:
struct AnimationTest: View {
#State private var show = false
var body: some View {
VStack {
if show {
Color.green
.transition(.slide)
} else {
Color.yellow
.transition(.slide)
}
Button(action: {
self.show.toggle()
}, label: {
Text("CHANGE VIEW!")
})
}
.animation(.linear)
}
}
I'm working on a WatchOS-app that needs to display an animation on top of another view while waiting for a task to finish.
My approach is the following (ConnectionView):
struct ConnectionView: View{
#EnvironmentObject var isConnected : Bool
var body: some View {
return VStack(alignment: .trailing){
ZStack{
ScrollView{
.....
}
if(!isConnected){
ConnectionLoadingView()
}
}
}
}
}
And for the ConnectionLoadingView:
struct ConnectionLoadView: View {
#State var isSpinning = false
#EnvironmentObject var isConnected : Bool
var body: some View {
var animation : Animation
///This is needed in order to make the animation stop when isConnected is true
if(!isConnected){
animation = Animation.linear(duration: 4.0)
}else{
animation = Animation.linear(duration: 0)
}
return Image(systemName: "arrowtriangle.left.fill")
.resizable()
.frame(width: 100, height: 100)
.rotationEffect(.degrees(isSpinning ? 360 : 0))
.animation(animation)
.foregroundColor(.green)
.onAppear(){
self.isSpinning = true
}
.onDisappear(){
self.isSpinning = false
}
}
}
The real problem consists of two parts:
On the very first ConnectionView that is displayed after the app is started, the ConnectionLoadView is displayed properly. On the subsequent runs the ConnectionLoadView has a weird "fade in" effect where it changes it's opacity throughout the animation (doesn't matter if I set the opacity for the view to 1, 0 or anything inbetween).
If I don't have the following code snippet in ConnectionLoadView:
if(!isConnected){
animation = Animation.linear(duration: 4.0)
}else{
animation = Animation.linear(duration: 0)
}
Without this the ConnectionView will continue to play the animation but move it from the foreground to background of the ZStack, behind the ScrollView, when it should just disappear straight away? Without this code snippet, the animation will only disappear as it should if the animation has stopped before the task has finished.
Is there any reason why the ConnectionLoadView is pushed to the background of the ZStack instead of just being removed from the view altogether when I clearly state that it should only be displayed if and only if !isConnected in ConnectionView?
I also can't quite figure out why there is a difference between the animation behaviour of the initial ConnectionView and the subsequent ones regarding the the opacity behaviour. Is the opacity changing part of the linear-animation?
Thanks!
You are approaching the animation wrong. You shouldn't use implicit animations for this. Explicit animations are better suited.
Implicit animations are the ones you apply with .animation(). This affect any animatable parameter that changes on a view.
Explicit animations are the ones you trigger with withAnimation { ... }. Only parameters affected by the variables modified inside the closure, are the ones that will animate. The rest will not.
The new code looks like this:
import SwiftUI
class Model: ObservableObject {
#Published var isConnected = false
}
struct Progress: View{
#EnvironmentObject var model: Model
var body: some View {
return VStack(alignment: .trailing){
ZStack{
ScrollView{
ForEach(0..<3) { idx in
Text("Some line of text in row # \(idx)")
}
Button("connect") {
self.model.isConnected = true
}
Button("disconect") {
self.model.isConnected = false
}
}
if !self.model.isConnected {
ConnectionLoadView()
}
}
}
}
}
struct ConnectionLoadView: View {
#State var isSpinning = false
#EnvironmentObject var model: Model
var body: some View {
return Image(systemName: "arrowtriangle.left.fill")
.resizable()
.frame(width: 100, height: 100)
.rotationEffect(.degrees(isSpinning ? 360 : 0))
.foregroundColor(.green)
.onAppear(){
withAnimation(Animation.linear(duration: 4.0).repeatForever(autoreverses: false)) {
self.isSpinning = true
}
}
}
}