Animate NSWindow's size change based on SwiftUI changes - swift

By default, an NSWindow that hosts a SwiftUI view will automatically resize to fit the size of the view if/when it changes. However, if the changes are animated, the window jumps to the final size without animating. Is there a way to animate the window size along with the content?
I've tried this with and without SwiftUI's lifecycle. I'v also tried calling the toggle method with NSAnimationContext.runAnimationGroup
struct ContentView: View {
#State var isExpanded = false
var body: some View {
VStack {
Button(isExpanded ? "Close" : "Open", action: toggleExpand)
if isExpanded {
ForEach(0..<5) { count in
Text("\(count). Some Item")
}
}
}
.padding()
.fixedSize()
}
func toggleExpand() {
withAnimation(.easeInOut(duration: 1)) { isExpanded.toggle() }
}
}

Related

Window size does not respect animation in SwiftUI-macOS

I want remove a green view with no animation but displace a blue view when the green view gone with animation.
The code is like this in below, this works well in iOS, but not in macOS, because in macOS the window size change with no respect to happening animation, how could I refactor my codes that window size change size responsible to animation, that I could be able to see the entire animation.
struct ContentView: View {
#State private var isPresented: Bool = true
var body: some View {
if (isPresented) {
Color.green.frame(height: 400.0)
}
Color.blue.frame(height: 100.0).onTapGesture {
isPresented.toggle()
}
.animation(Animation.linear(duration: 3.0), value: isPresented)
}
}
ios:
macOS:
updated part for macOS:
struct ContentView: View {
#State private var isPresented: Bool = true
var body: some View {
VStack {
Color.green.frame(height: isPresented ? 400.0 : 0.0)
Color.blue.frame(height: 100.0).onTapGesture {
isPresented.toggle()
}
}
.animation(Animation.linear(duration: 3.0), value: isPresented)
}
}

NavigationView frame glitch with safearea

I try to create a custom ViewModifier similar to SwiftUI's .sheet modifier.
When I try to make a NavigationView spring from bottom, the frame of the view just glitched over safearea. The frame looks as if adjust to the safearea when the view moves from bottom to top.
Anyone knows maybe how to constrain the view frame inside the navigation view to avoid this?
Here is what happened. When click the plus button, the SwiftUI .sheet modifier shows up. Custom popup shows up when pressing the gear button.
Problem gif recording here
Here is code of the custom popup view.
struct SettingsView: View {
#Binding var showingSelf: Bool
#Binding var retryWrongCards: Bool
var body: some View {
GeometryReader { geometry in
NavigationView {
List {
Section {
Toggle(isOn: $retryWrongCards) {
Text("Retry Wrong Cards")
}
}
}
.animation(nil)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationBarTitle("Settings")
.navigationBarItems(trailing: Button("Done") {
self.showingSelf = false
})
}
}
}
}
Here's the code of custom modifier
struct Popup<T: View>: ViewModifier {
let popup: T
let isPresented: Bool
init(isPresented: Bool, #ViewBuilder content: () -> T) {
self.isPresented = isPresented
popup = content()
}
func body(content: Content) -> some View {
content
.overlay(popupContent())
}
#ViewBuilder private func popupContent() -> some View {
GeometryReader { geometry in
if isPresented {
popup
.animation(.spring())
.transition(.offset(x: 0, y: geometry.belowScreenEdge))
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
}
private extension GeometryProxy {
var belowScreenEdge: CGFloat {
UIScreen.main.bounds.height - frame(in: .global).minY
}
}
After debugging and trying many changes, I was already preparing a mini-project to file a SwiftUI bug for Apple. In the end, it was not necessary and we have a simple solution 🥳!!
TLDR; your SettingsView does not correctly initialise the NavigationView.
The modifier .navigationViewStyle(StackNavigationViewStyle()) has to be applied onto the NavigationView and not inside it (in contrast to navigationBarTitle or navigationBarItems which only work when put inside the NavigationView):
NavigationView {
List {
Section {
Toggle(isOn: $retryWrongCards) {
Text("Retry Wrong Cards")
}
}
}
.animation(nil)
.listStyle(GroupedListStyle())
.navigationBarTitle("Settings")
.navigationBarItems(trailing: Button("Done") {
self.showingSelf = false
})
}
.navigationViewStyle(StackNavigationViewStyle())
After this change your custom sheet modifier behaves for me identically as the regular .sheet modifier! Great work!
Philipp
It seems your animation does exactly what a sheet does. I would just replace it with that. You can easily present a sheet on top of a sheet.
Edit
After you posted your ViewModifier code, it seems the only difference is the animation type and the size of the popup.

SwiftUI: Animate changes in List without animating content changes

I have a simple app in SwiftUI that shows a List, and each item is a VStack with two Text elements:
var body: some View {
List(elements) { item in
NavigationLink(destination: DetailView(item: item)) {
VStack(alignment: .leading) {
Text(item.name)
Text(self.distanceString(for: item.distance))
}
}
}
.animation(.default)
}
The .animate() is in there because I want to animate changes to the list when the elements array changes. Unfortunately, SwiftUI also animates any changes to content, leading to weird behaviour. For example, the second Text in each item updates quite frequently, and an update will now shortly show the label truncated (with ... at the end) before updating to the new content.
So how can I prevent this weird behaviour when I update the list's content, but keep animations when the elements in the list change?
In case it's relevant, I'm creating a watchOS app.
The following should disable animations for row internals
VStack(alignment: .leading) {
Text(item.name)
Text(self.distanceString(for: item.distance))
}
.animation(nil)
The answer by #Asperi fixed the issue I was having also (Upvoted his answer as always).
I had an issue where I was animating the whole screen in using the below: AnyTransition.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top))
And all the Text() and Button() sub views where also animating in weird and not so wonderful ways. I used animation(nil) to fix the issue after seeing Asperi's answer. However the issue was that my Buttons no longer animated on selection, along with other animations I wanted.
So I added a new State variable to turn on and off the animations of the VStack. They are off by default and after the view has been animated on screen I enable them after a small delay:
struct QuestionView : View {
#State private var allowAnimations : Bool = false
var body : some View {
VStack(alignment: .leading, spacing: 6.0) {
Text("Some Text")
Button(action: {}, label:Text("A Button")
}
.animation(self.allowAnimations ? .default : nil)
.onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.allowAnimations = true
}
}
}
}
Just adding this for anyone who has a similar issue to me and needed to build on Asperi's excellent answer.
Thanks to #Brett for the delay solution. My code needed it in several places, so I wrapped it up in a ViewModifier.
Just add .delayedAnimation() to your view.
You can pass parameters for defaults other than one second and the default animation.
import SwiftUI
struct DelayedAnimation: ViewModifier {
var delay: Double
var animation: Animation
#State private var animating = false
func delayAnimation() {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.animating = true
}
}
func body(content: Content) -> some View {
content
.animation(animating ? animation : nil)
.onAppear(perform: delayAnimation)
}
}
extension View {
func delayedAnimation(delay: Double = 1.0, animation: Animation = .default) -> some View {
self.modifier(DelayedAnimation(delay: delay, animation: animation))
}
}
In my case any of the above resulted in strange behaviours. The solution was to animate the action that triggered the change in the elements array instead of the list. For example:
#State private var sortOrderAscending = true
// Your list of elements with some sorting/filtering that depends on a state
// In this case depends on sortOrderAscending
var elements: [ElementType] {
let sortedElements = Model.elements
if (sortOrderAscending) {
return sortedElements.sorted { $0.name < $1.name }
} else {
return sortedElements.sorted { $0.name > $1.name }
}
}
var body: some View {
// Your button or whatever that triggers the sorting/filtering
// Here is where we use withAnimation
Button("Sort by name") {
withAnimation {
sortOrderAscending.toggle()
}
}
List(elements) { item in
NavigationLink(destination: DetailView(item: item)) {
VStack(alignment: .leading) {
Text(item.name)
}
}
}
}

What is the correct way to update ScrollView in SwiftUI? (MacOS App)

I am using officially released Xcode 11 from appstore.
A number of things can be seen if you run the code below.
Tapping on Root Press correctly adds the missing view#2, But there is no animation. Why is there no animation?
Tapping any of the Press buttons, correctly adds a middle view, but if you look, you will see that the scrollView content size did not update and therefore the content is clipped. What is the correct way to update the ScrollView?
import SwiftUI
struct ContentView: View {
#State var isPressed = false
var body: some View {
VStack {
Button("Root Press") { withAnimation { self.isPressed.toggle() } }
ScrollView {
Group {
SampleView(index: 1)
if isPressed { SampleView(index: 2) }
SampleView(index: 3)
SampleView(index: 4)
}
.border(Color.red)
}
}
}
}
struct SampleView: View {
#State var index: Int
#State var isPressed = false
var body: some View {
HStack {
VStack {
Text("********************")
Text("This View = \(index)")
Text("********************")
if isPressed {
Text("********************")
Text("-----> = \(index)")
Text("********************")
}
}
Button("Press") { withAnimation { self.isPressed.toggle() } }
}
}
}
Fixing the animations can be done via: .animation(.linear(duration: 0.3)). You can then remove all the animation blocks. (withAnimation { }). As for the bounds/frame, setting the frame helps (when adding the row at the root level), but it doesn't seem to work when you are dealing with an inner view. I added the following: .frame(width:UIScreen.main.bounds.width), and it will look like the following:

`withAnimation` only does animation once when adding first item to #State array

My goal is to have control over the type of animation when an object is added to the #State events array.
withAnimation only occurs on the first append to the events array. It is then ignored on additional appends.
I'm currently running this on Xcode 11 beta 4
I've tried adding the calling DispatchQueue.main.async, having the animation on the Text() object.
If I use a list it performs animation on addition, however I don't know how to modify those animations.
Goal
Have text slide in with each append and fade out on each remove.
struct Event: Identifiable {
var id = UUID()
var title: String
}
struct ContentView: View {
#State
var events = [Event]()
var body: some View {
VStack {
ScrollView {
ForEach(events) { event in
Text(event.title)
.animation(.linear(duration: 2))
}
}
HStack {
Button(action: {
withAnimation(.easeOut(duration: 1.5)) {
self.events.append(Event(title: "Animate Please"))
}
}) {
Image(systemName: "plus.circle.fill").resizable().frame(width: 40, height: 40, alignment: .center)
}
}
}
}
}
I'm expecting that each append has an animation that is described in the withAnimation block.
When SwiftUI layouts and animations behave in ways you think are not correct, I suggest you add borders. The outcome may surprise you and point you directly into the cause. In most cases, you'll see that SwiftUI was actually right! As in your case:
Start by adding borders:
ScrollView {
ForEach(events) { event in
Text(event.title)
.border(Color.red)
.animation(.linear(duration: 2))
}.border(Color.blue)
}.border(Color.green)
When you run your app, you'll see that before adding your first array element, the ScrollView is collapsed into zero width. That is correct, as the ScrollView is empty. However, when you add your first element, it needs to be expanded to accommodate the "Animate Please" text. The Text() view also starts with zero width, but as its containing ScrollView grows, it does too. These are the changes that get animated.
Now, when you add your second element, there is nothing to animate. The Text() view is placed with its final size right from the start.
If instead of "Animate Please", you change your code to use a random length text, you will see that when adding a largest view, animations do occur. This is because ScrollView needs to expand again:
self.events.append(Event(title: String(repeating: "A", count: Int.random(in: 0..<20))))
What next: You have not explained in your question what animation you expect to see. Is it a fade-in? A slide? Note that in addition to animations, you may define transitions, which determines the type of animation to perform when a view is added or removed from your hierarchy.
If after putting these tips into practice, you continue to struggle, I suggest you edit your question and tell us exactly what animation would you like to see when adding a new element to your array.
UPDATE
According to your comments, you want the text to slide. The simplest form, is using a transition. Unfortunately, the ScrollView seems to disable transitions on its children. I don't know if that is intended or a bug. Anyway, here I post two methods. One with transitions (does not work with ScrollView) and one using only animations, which does work inside a ScrollView, but requires more code:
With transitions (does not work inside a ScrollView)
struct ContentView: View {
#State private var events = [Event]()
var body: some View {
VStack {
ForEach(events) { event in
// A simple slide
Text(event.title).transition(.slide).animation(.linear(duration: 2))
// To specify slide direction
Text(event.title).transition(.move(edge: .trailing)).animation(.linear(duration: 2))
// Slide combined with fade-in
Text(event.title).transition(AnyTransition.slide.combined(with: .opacity)).animation(.linear(duration: 2))
}
Spacer()
HStack {
Button(action: {
self.events.append(Event(title: "Animate Please"))
}) {
Image(systemName: "plus.circle.fill").resizable().frame(width: 40, height: 40, alignment: .center)
}
}
}
}
}
Without transitions (works inside a ScrollView):
struct Event: Identifiable {
var id = UUID()
var title: String
var added: Bool = false
}
struct ContentView: View {
#State var events = [Event]()
var body: some View {
VStack {
ScrollView {
ForEach(0..<events.count) { i in
// A simple slide
Text(self.events[i].title).animation(.linear(duration: 2))
.offset(x: self.events[i].added ? 0 : 100).opacity(self.events[i].added ? 1 : 0)
.onAppear {
self.events[i].added = true
}
}
HStack { Spacer() } // This forces the ScrollView to expand horizontally from the start.
}.border(Color.green)
HStack {
Button(action: {
self.events.append(Event(title: "Animate Please"))
}) {
Image(systemName: "plus.circle.fill").resizable().frame(width: 40, height: 40, alignment: .center)
}
}
}
}
}