I'm attempting to transition a button in a SwiftUI project such that, when the button is pressed, it will do a .move(edge: .trailing) and after approximately a half a second a different image for the button will come into view.
Here's what I have so far and as expected the image changes simultaneously. I'm curious if this is something that can be accomplished with asymmetric transitions. Trying to avoid using two different buttons that have animating offsets changing.
#State var delayedMove = false
#ViewBuilder
var moveMe: some View {
HStack {
Spacer()
Button(action: {
withAnimation {
delayedMove.toggle()
}
}) {
if delayedMove {
selectImageButton
.animation(.linear.delay(delayedMove ? 0 : 1))
.transition(.move(edge: .trailing))
} else {
deleteImage
.animation(.linear.delay(!delayedMove ? 0 : 1))
.transition(.move(edge: .trailing))
}
}
.padding()
}
}
Yes you can use asymmetric, and no need to repeat the code. But Transition in SwiftUI needs 2 things to works correctly first a Group and Second an if & else, it is how it works right now maybe in future it changes but you can see the Code:
struct ContentView: View {
#State private var toggleButton = false
var body: some View {
HStack {
Button("Toggle Button") { toggleButton.toggle() }
Spacer()
Button(action: { toggleButton.toggle() }, label: {
Group {
if toggleButton {
Image(systemName: "trash")
}
else {
Image(systemName: "plus")
}
}
.transition(AnyTransition.asymmetric(insertion: AnyTransition.move(edge: .trailing), removal: AnyTransition.move(edge: .trailing)))
})
}
.padding()
.animation(Animation.linear(duration: 0.5), value: toggleButton)
}
}
You are quite close. I just split up the single if-else into 2 ifs, wrapped in a ZStack.
The button also makes secondaryDelay equal true for half a second, and then it switches back to false.
When the view leaves the hierarchy, that's when the transition starts. I worked out the way to do this by considering all the scenarios for the selectImageButton:
Showing - show: delayedMove = true, secondaryDelay = false
Starting to hide - hide: delayedMove = false, secondaryDelay = true
Hiding - hide: delayedMove = false, secondaryDelay = false
Starting to show - hide: delayedMove = true, secondaryDelay = true
From above, we only need to show when delayedMove && !secondaryDelay equals true. Similar method for other deleteImage.
Code:
struct ContentView: View {
#State var delayedMove = false
#State var secondaryDelay = false
var body: some View {
moveMe
}
#ViewBuilder
var moveMe: some View {
HStack {
Spacer()
Button(action: {
withAnimation {
delayedMove.toggle()
secondaryDelay = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
secondaryDelay = false
}
}
}) {
ZStack {
if delayedMove && !secondaryDelay {
selectImageButton
.animation(.linear)
.transition(.move(edge: .trailing))
}
if !delayedMove && !secondaryDelay {
deleteImage
.animation(.linear)
.transition(.move(edge: .trailing))
}
}
}
.padding()
}
}
}
Result:
You can still use the same button and have the label animated with an offset if you want a clean and smooth animation like the first image going out and the other entering. you can try this view
#State var delayedMove = false
var moveMe: some View {
HStack {
Spacer()
Button(action: {
withAnimation (.linear(duration: 0.5)){
delayedMove.toggle()
}
}) {
ZStack{
Image(systemName: "trash")
.offset(x: delayedMove ? 0 : 200)
Image(systemName: "plus")
.offset(x: delayedMove ? 200 : 0)
}
.imageScale(.large)
}
.padding()
}
}
Related
So my goal is to be able to show a custom view from time to time over a SwiftUI tabview, so I thought I would place them both in a ZStack like this
#State var show = true
#State private var selectedTab : Int = 0
var body: some View {
ZStack {
TabView(selection: $selectedTab) {
Color.pink
}
if show {
Button(action: {
withAnimation(Animation.linear(duration: 10)) {
show = false
}
}) {
Color.blue
}
.frame(width: 100, height: 100)
}
}
}
This works just fine, but when I try to use withAnimation() no animation gets triggered. How can I make the overlaying view, disappear with animation?
Use .animation modifier with container, like below, so container could animate removing view
ZStack {
TabView(selection: $selectedTab) {
Color.pink
}
if show {
Button(action: {
show = false // << withAnimation not needed anymore
}) {
Color.blue
}
.frame(width: 100, height: 100)
}
}
.animation(Animation.linear(duration: 10), value: show) // << here !!
So I found a solution and what I think is the cause of this. My hypothesis is that the animation modifier does not handle ZIndex IF it is not explicitly set.
One solution to this is to set ZIndex to the view that should be on the top to something higher than the other view. Like this:
#State var show = true
#State private var selectedTab : Int = 0
var body: some View {
ZStack {
TabView(selection: $selectedTab) {
Color.pink
}
if show {
Button(action: {
withAnimation {
show = false
}
}) {
Color.blue
}
.frame(width: 100, height: 100)
.zIndex(.infinity) // <-- this here makes the animation work
}
}
}
I have a view with a toolbar on the bottom of the view. When clicked - two buttons are displayed. I am trying to achieve when the toolbar is pressed and the buttons are now displayed, the view (or background) becomes blurred/grayed out, except for the newly produced items.
I attached a screenshot of the desired effect I am aiming for.
struct UserDashController: View {
// #State private var showMealView = false
#State private var showSettingsView = false
#State private var showAddViews = false
#State private var angle: Double = 0
init(){
UIToolbar.appearance().barTintColor = UIColor.white
}
var body: some View {
NavigationView {
VStack{
Text("Blue me Please")
.frame(width: 400, height:600)
.background(.orange)
}
//sets setting bar top right
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
VStack{
Button(action: {
showSettingsView.toggle()
}) {
Image(systemName: "line.3.horizontal")
.font(.title3)
.foregroundColor(.black)
}
.sheet(isPresented: $showSettingsView){
JournalEntryMain()
}
}
}
// sets add meal option bottom/center
ToolbarItem(placement: .bottomBar) {
//displaying add meal and recipe icons when clicked
HStack{
Button(action: {
angle += 90
showAddViews.toggle()
}) {
if showAddViews {
VStack{
AddToolbar(showAddOptions: $showAddViews)
.offset(y:-50)
}
}
Image(systemName: "plus.square")
.opacity(showAddViews ? 0.5 : 1)
.font(.largeTitle)
.foregroundColor(.black)
.rotationEffect(.degrees(angle))
.animation(.easeIn(duration: 0.25), value: angle)
}
}
}
}
}
}
}
Buttons that appear when toolbar is pressed
struct AddToolbar: View {
#Binding var showAddOptions: Bool
#State var showMealView = false
var body: some View {
HStack{
VStack{
Button(action: {
showMealView.toggle()
}){
VStack{
Image(systemName: "square.and.pencil")
.font(.title)
.foregroundColor(.black)
.background(Circle()
.fill(.gray)
.frame(width:50, height:50))
.padding(3)
Text("Meal")
.foregroundColor(.black)
}
}.fullScreenCover(isPresented: $showMealView){
JournalEntryMain()
}
}
VStack{
Image(systemName: "text.book.closed")
.foregroundColor(.black)
.font(.title)
.background(Circle()
.fill(.gray)
.frame(width:50, height:50))
.padding(3)
Text("Recipe")
.foregroundColor(.black)
}
.offset(y: -50)
}
.frame(height:150)
}
}
Desired Effect
I'm a little confused by your desired effect example, partially because in the UI screenshot you attached, the background isn't blurred, it's just darkened. So, the following answer isn't tailored to your specific example but still should be able to help.
Let's say whatever variable you're using to determine whether or not to show the toolbar is showSettingsView. You could put the following modifiers on your background view:
To blur: .blur(showSettingsView ? 0.5 : 0.0)
To darken: .brightness(showSettingsView ? -0.5 : 0.0)
Obviously just replace "0.5" with whatever number feels best.
I have this code:
Group{
if self.done{
Image(systemName: "plus").font(.system(size: 40)).foregroundColor(.gray)
.padding().overlay(Circle().stroke(Color.gray, lineWidth: 3)).opacity(0.6)
}
else{
Button(action: {self.showSearchTrash = 1}){
Image(systemName: "plus").font(.system(size: 40)).foregroundColor(green)
.padding().overlay(Circle().stroke(green, lineWidth: 3).scaleEffect(1+self.animationAmount).animation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)).onAppear {self.animationAmount = 0.1})
}
}
}.padding(.bottom, 5)
And the intention is that if self.done is false, the circle on the plus button will expand and contract indefinitely.
This works. However, if I use a toggle to set self.done to be true and then turn it back to false, the animation no longer occurs. I know that the issue is not with the toggle, because it does return to being green.
Also, the lack of . before green is intentional - I defined a specific Color green.
Any idea why the animation stops working/how to fix this?
Actually works fine as-is with Xcode 12.1 / iOS 14.1, so you might observe either bug of new iOS version or result of some other code.
Anyway, I would added turn-offs scaling on button disappear:
Button(action: {}){
Image(systemName: "plus").font(.system(size: 40)).foregroundColor(green)
.padding().overlay(Circle().stroke(green, lineWidth: 3).scaleEffect(1+self.animationAmount).animation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)))
}
.onAppear {self.animationAmount = 0.1} // << put here !!
.onDisappear {self.animationAmount = 0} // << add this !!
You can specify Animation in the withAnimation block and create separate functions for starting/stopping the animation.
Here is a possible solution:
struct ContentView: View {
#State private var done = false
#State private var animationAmount: CGFloat = 0
var body: some View {
VStack {
Toggle("Done", isOn: $done)
plusImage
.opacity(done ? 0.6 : 1)
.foregroundColor(done ? .gray : .green)
}
.onAppear(perform: startAnimation)
.onChange(of: done) { done in
if done {
stopAnimation()
} else {
startAnimation()
}
}
}
var plusImage: some View {
Image(systemName: "plus")
.font(.system(size: 40))
.padding()
.overlay(
Circle()
.stroke(Color.gray, lineWidth: 3)
.scaleEffect(1 + animationAmount)
)
}
}
private extension ContentView {
func startAnimation() {
withAnimation(Animation.easeInOut(duration: 1).repeatForever()) {
animationAmount = 0.1
}
}
func stopAnimation() {
withAnimation {
animationAmount = 0
}
}
}
I want to make a modal that works cross platform on iOS and Mac.
The issue is if I toggle the modal rapidly—there's a strange behavior. Attached GIF with the weird behavior below. Is this SwiftUI Bug?
If not, What did I do wrong?
EDIT: more info.
Without animation and transition, the interaction works as expected.
Here's the code on Playground
import SwiftUI
import PlaygroundSupport
struct CView: View {
#State var isShown = false
var body: some View {
ZStack {
Color.white
ZStack {
Color.green
VStack {
HStack {
Spacer()
Text("Open").onTapGesture {
self.isShown = true
}
}
Spacer()
}
}
.edgesIgnoringSafeArea(.all)
.zIndex(0)
if self.isShown {
ZStack {
Color.red
VStack {
HStack {
Spacer()
Text("Close").onTapGesture {
self.isShown = false
}
}
Spacer()
}
}
.transition(AnyTransition.move(edge: .bottom))
.animation(.easeInOut)
.edgesIgnoringSafeArea(.all)
.zIndex(1)
}
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.setLiveView(CView())
``
[1]: https://i.stack.imgur.com/21z7z.gif
Here is a solution. Tested with Xcode 11.5b
struct CView: View {
#State private var isShown = false
#State private var showing = false
var body: some View {
ZStack {
Color.white // needed ??
ZStack {
Color.green
VStack {
HStack {
Spacer()
Text("Open").onTapGesture {
self.isShown = true
}
}
Spacer()
}
}.disabled(showing) // << inactive on in-progress
// .edgesIgnoringSafeArea(.all) // ?? bad visibility
.zIndex(1) // by default it is always 0, so make above white
if self.isShown {
ZStack {
Color.red
VStack {
HStack {
Spacer()
Text("Close").onTapGesture {
self.isShown = false
}
}
Spacer()
}
}
.transition(AnyTransition.move(edge: .bottom))
.animation(.easeInOut)
// .edgesIgnoringSafeArea(.all) // ??
.zIndex(2) // top-most
.onAppear {
self.showing = true
}
.onDisappear {
self.showing = false
}
}
}
}
}
Using SwiftUI, I have a view that includes a slider that I'm using a transition to slide in from the bottom. All works well, until the slider is moved quickly back and forth. With that, the text field is being animated, and will show "..." when changing from 1 to two digits.
Here is my test code showing this:
struct TestSliderView: View {
#State private var val: Double = 0
#State private var showSlider: Bool = false
var body: some View {
VStack {
Button(action: {
self.showSlider.toggle()
}) {
Text("Show Slider")
}
Spacer()
if showSlider {
JustTheSlider(val: $val)
.padding()
.transition(.move(edge: .bottom))
.animation(.linear(duration: 0.4))
}
}
}
}
struct JustTheSlider: View {
#Binding var val: Double
var body: some View {
VStack {
Text("Slider")
.font(.title)
HStack {
Text("Value: ")
.frame(minWidth: 80, alignment: .leading)
Slider(value: $val, in: 0...30, step: 1)
Text("\(Int(val))")
.frame(minWidth: 20, alignment: .trailing)
.font(Font.body.monospacedDigit())
.padding(.horizontal)
}
}
}
}
One way around this would be to remove the .animation(.linear(duration: 0.4)) line and wrap the button action with an animation like so:
Button(action: {
withAnimation(.linear(duration: 0.4)) {
self.showSlider.toggle()
}
}) {
Text("Show Slider")
}
This stops the text from animating, but then the view only slides out, and just pops in without any slide animation.
Any ideas?
You need animate the state variable, not the View.
var body: some View {
VStack {
Button(action: {
withAnimation{
self.showSlider.toggle()}
}) {
Text("Show Slider")
}
Spacer()
if showSlider {
JustTheSlider(val: $val)
.padding()
.transition(.move(edge: .bottom))
}
}
}
As the last line shown.