SwiftUI Transitions have different implementation? - swiftui

I have been testing how transitions work based on SwiftUI-labs but I found that transitions should not all be implemented in the same way.
Based on the previous article:
Note that since XCode 11.2, transitions no longer work with implicit
animations
Thing is, that I found something strange. Some transitions work fine when using either implicit animation OR by associating an animation to it. So, what animation should be used? Well, it depends. On what? I don't know. I'm hoping anyone can help me explaining all this.
In the following test, I have created 5 views, each one associated with a different transition: .opacity,.scale, .move, .slide, and combined(.opacity and .move). Here are my findings:
NOTE: I'M USING XCODE 11.4 AND THE SIMULATOR!
Transitions using implicit animation
Only .move and .slide transitions work OK (removal and insertion).
Transitions using explicit animation
Removal and insertion animation works OK for all transitions.
Associating an animation to each transition
Only .scale and .opacity transitions work OK (removal and insertion).
From my point of view, not having a standard way of implementing transitions complicates things, moreover when combining transitions (.combined).
Am I missing something about the implementation? Here's my code:
struct TestAnimation: View {
#State var show : Bool = true
var colors : [Color] = [.orange, .yellow, .green, .blue, .pink]
var body: some View {
VStack(alignment: .leading) {
Spacer()
Color.purple
.frame(height: 100)
.overlay(
Text("Tap Me!").foregroundColor(.white))
.onTapGesture {
// (#1) implicit animation
self.show.toggle()
// (#2) explicit animation
/*withAnimation(Animation.easeInOut(duration: 1)) {
self.show.toggle()
}*/
// (#3) associate an animation with a transition
//self.show.toggle()
}
HStack {
if show {
Rectangle()
.fill(colors[0])
.frame(width: 70, height: 100)
.overlay(Text("opacity"))
.transition(AnyTransition.opacity) // (#1) - doesn't animate, only removes/inserts the view
.animation(.easeInOut(duration: 1)) // (#1)
//.transition(AnyTransition.opacity) // (#2) - only animates the removal, not the insertion
//.transition(AnyTransition.opacity.animation(.easeInOut(duration: 1))) // (#3) - animates removal and insertion
Rectangle()
.fill(colors[1])
.frame(width: 70, height: 100)
.overlay(Text("scale"))
.transition(AnyTransition.scale) // (#1) - doesn't animate, only removes/inserts the view
.animation(.easeInOut(duration: 1)) // (#1)
//.transition(AnyTransition.scale) // (#2) - only animates the removal, not the insertion
//.transition(AnyTransition.scale.animation(.easeInOut(duration: 1))) // (#3) - animates removal and insertion
Rectangle()
.fill(colors[2])
.frame(width: 70, height: 100)
.overlay(Text("move"))
.transition(AnyTransition.move(edge: .bottom)) // (#1) - animates removal and insertion
.animation(.easeInOut(duration: 1)) // (#1)
//.transition(AnyTransition.move(edge: .bottom)) // (#2) - only animates the removal, not the insertion
//.transition(AnyTransition.move(edge: .bottom).animation(.easeInOut(duration: 1))) // (#3) - doesn't animate, only removes/inserts the view
Rectangle()
.fill(colors[3])
.frame(width: 70, height: 100)
.overlay(Text("slide"))
.transition(AnyTransition.slide) // (#1) - animates removal and insertion
.animation(.easeInOut(duration: 1)) // (#1)
//.transition(AnyTransition.slide) // (#2) - only animates the removal, not the insertion
//.transition(AnyTransition.slide.animation(.easeInOut(duration: 1))) // (#3) - doesn't animate, only removes/inserts the view
Rectangle()
.fill(colors[4])
.frame(width: 70, height: 100)
.overlay(Text("op&mv"))
.transition(AnyTransition.opacity.combined(with: .move(edge: .bottom))) // (#1) - doesn't animate, only removes/inserts the view
.animation(.easeInOut(duration: 1)) // (#1)
//.transition(AnyTransition.opacity.combined(with: .move(edge: .bottom))) // (#2) - only animates removal, not the insertion
//.transition(AnyTransition.opacity.combined(with: .move(edge: .bottom)).animation(.easeInOut(duration: 1))) // (#3) - animates removal (it's not smooth), and only animates opacity on insertion
}
}
.frame(height: 100)
.padding(7)
.border(Color.gray)
}
}
}

Related

SwiftUI - Drag Gesture doesn't activate on HStack with Buttons in it

I have an HStack with buttons in it defined like this:
GeometryReader { proxy in
HStack {
Button("test")
.frame(width: 100, height: 50)
Button("test")
.frame(width: 100, height: 50)
Button("test")
.frame(width: 100, height: 50)
}
.frame(width: proxy.size.width)
.gesture(
.onChanged { gesture in
// do something here
}
)
}
As you can see, I am trying to perform some function when the HStack is dragged. This works if the user drags starting from an empty space in the HStack, however, if the drag starts from the button, the gesture is not activated. How can I fix this?
You can use a View with an onTapGesture instead of a button.
Try something like this:
#State private var count = 0 // To check if dragging works
GeometryReader { proxy in
HStack {
Text("test")
.onTapGesture { // The action gesture
//
}
.foregroundColor(.blue) // Colour it like the default button
.frame(width: 100, height: 50)
Text("\(count)")
}
.gesture(DragGesture(minimumDistance: 1) // The dragging gesture
.onChanged { gesture in
count += 1
})
}
You could possibly also try and disabling the button when dragging and then enable when you're done dragging.
Good luck!

Scrolling inside a ScrollView in SwiftUI

In a giant ScrollView, I want to place Circles, and scroll to them so that, when scrolled, they end up in the middle of the screen, one after another.
I can position the Circles either by using .position, .offset or .padding. I have positioned them (300,300) away from one-another, and so that none of them are on screen when the view is loaded.
When I scroll to the ones positioned with .position or .offset, the ScrollView scrolls to the top left. .offset scrolls with an inset, .position all the way. When I scroll to the one positioned with .padding, it is not centred.
What am I doing wrong here? Why will none of my three attempts scroll so that the circle in question is placed in the middle of the ScrollView?
struct CanvasTester: View {
#State var i = 0
var body: some View {
ZStack(alignment: .topLeading) {
ScrollViewReader { scrollView in
ScrollView([.horizontal, .vertical]) {
ZStack(alignment: .topLeading) {
Spacer()
.frame(width: 4096, height: 4096)
.background(Color.yellow)
.task {
#Sendable func f() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
withAnimation {
scrollView.scrollTo("circle\(i+1)", anchor: .center)
self.i = (self.i + 1) % 3
}
f()
}
}
f()
}
Circle()
.stroke(Color.blue, lineWidth: 4)
.frame(width: 44, height: 44)
.offset(x: 1500, y: 1500)
.id("circle1")
Circle()
.stroke(Color.green, lineWidth: 4)
.frame(width: 44, height: 44)
.position(x: 1800, y: 1800)
.id("circle2")
Circle()
.stroke(Color.red, lineWidth: 4)
.frame(width: 44, height: 44)
.padding([.top, .leading], 2100)
.id("circle3")
}
}
}
Text("#\(i+1)")
}
}
}
Quick run-through the code: I have a ScrollReader encapsulating a ScrollView that scrolls both directions and is 4096x4096. It has a yellow background that on draw launches a function that every second scrolls to the view with either ID "circle1", "circle2" or "circle3". Then follow the three circles with those labels, and finally a label I the top left corner to indicate what color number the ID has.

SwiftUI - view expand from the bottom of frame

I'd like to display a number of values as a continuous value from 0 to 1. I'd like them to grow from the bottom up, from 0 displaying no value, to 1 displaying a full height.
However, I'm unable to make it "grow from the bottom". I'm not sure what a better term for this is - it's a pretty simple vertical gauge, like a gas gauge in a car. I'm able to make it grow from the middle, but can't seem to find a way to make it grow from the bottom. I've played with mask and clipShape and overlay - but it must be possible to do this with just a simple View, and calculations on its height. I'd specifically like to able to show overlapping gauges, as the view below demonstrates.
My ContentView.swift is as follows:
import SwiftUI
struct ContentView: View {
// binding values with some defaults to show blue over red
#State var redPct: CGFloat = 0.75
#State var bluePct: CGFloat = 0.25
let DISP_PCT = 0.8 // quick hack - the top "gauge" takes this much so the sliders display below
var body: some View {
GeometryReader { geom in
VStack {
ZStack {
// neutral background
Rectangle()
.fill(Color.gray)
.frame(width: geom.size.width, height: geom.size.height * DISP_PCT)
// the first gauge value display
Rectangle()
.fill(Color.red)
.frame(width: geom.size.width, height: geom.size.height * DISP_PCT * redPct)
// the second gauge value, on top of the first
Rectangle()
.fill(Color.blue)
.frame(width: geom.size.width, height: geom.size.height * DISP_PCT * bluePct)
}
HStack {
Slider(value: self.$redPct, in: 0...1)
Text("Red: \(self.redPct, specifier: "%.2f")")
}
HStack {
Slider(value: self.$bluePct, in: 0...1)
Text("Red: \(self.bluePct, specifier: "%.2f")")
}
}
}
}
}
As you play with the sliders, the red/blue views grows "out" from the middle. I would like them to grow "up" from the bottom of its containing view.
I feel like this is poorly worded - if any clarification is needed, please don't hesitate to ask!
Any help would be greatly appreciated!
You can't have them all in the same stacks. The easiest way to do this is to have your gray rectangle be your case view, and then overlay the others on top in VStacks with Spacers like this:
// neutral background
Rectangle()
.fill(Color.gray)
.frame(width: geom.size.width, height: geom.size.height * DISP_PCT)
.overlay (
ZStack {
VStack {
Spacer()
// the first gauge value display
Rectangle()
.fill(Color.red)
.frame(width: geom.size.width, height: geom.size.height * DISP_PCT * redPct)
}
VStack {
Spacer()
// the second gauge value, on top of the first
Rectangle()
.fill(Color.blue)
.frame(width: geom.size.width, height: geom.size.height * DISP_PCT * bluePct)
}
}
)
The overlay contains them, and the spacers push your rectangles down to the bottom of the stacks.

SwiftUI: Shadow glitch after context menu

I have a rectangle with a shadow and a context menu. When I close this context menu the shadow of the rectangle appears with a delay (~0.5 seconds). Both the shadow of the complete rectangle as well the shadow of the inner elements. I'm not sure what I am doing wrong.
struct Playground: View {
var body: some View {
VStack(alignment: .leading, spacing: 4.0) {
Spacer()
HStack {
Spacer()
Image(systemName: "house")
.font(.system(size: 50))
Spacer()
}
Text("SwiftUI for iOS 14").fontWeight(.bold).foregroundColor(Color.white)
Text("20 Sections").font(.footnote).foregroundColor(Color.white)
}
.frame(width: 200, height: 300)
.padding(.all)
.background(Color.blue)
.cornerRadius(20.0)
.shadow(radius: 10)
.contentShape(RoundedRectangle(cornerRadius: 20))
.contextMenu(menuItems: {
Text("Menu Item 1")
Text("Menu Item 2")
Text("Menu Item 3")
})
}
}
I found a hacky way to mitigate the issue. This is using pure UIKit and Objective-C but it might be able to be done in SwiftUI too in one way or another.
This is a temporary direct-via-subview access to the view that animates, and I simply attach a shadow to it. This way the animating view will have a shadow and when the REAL shadow pops in, it does so below this shadow. The result looks fine.
-(void)contextMenuInteraction:(UIContextMenuInteraction *)interaction
willEndForConfiguration:(UIContextMenuConfiguration *)configuration
animator:(id<UIContextMenuInteractionAnimating>)animator {
// TODO: Find view dynamically /safer
UIView *platter = (UIView*)self.view.window.subviews[1].subviews[1].subviews[0];
platter.clipsToBounds = NO;
platter.layer.shadowOffset = CGSizeMake(0, 0);
platter.layer.shadowRadius = 5;
platter.layer.shadowOpacity = 0.5;
platter.layer.shadowColor = [UIColor blackColor].CGColor;
}
This works in iOS 13. Have not tried on iOS 14.

Reversed animation with repeatForever()

I'm trying to implement an error badge with a pulsating circle in the background. whenever I click on it multiple time it pulses inwards rather than outwards. I'm unable to understand this behaviour. Please suggest me the correct approach.
Image(systemName: "exclamationmark")
.background(
ZStack{
Circle()
.fill(Color.red.opacity(0.3))
.frame(width: 20, height: 20)
Circle()
.fill(Color.red.opacity(0.4))
.frame(width: 20, height: 20)
.scaleEffect(carError ? 1.5 : 1)
.onTapGesture(perform: {
carError.toggle()
})
}
.animation(carError ? Animation.easeIn(duration: 0.7).repeatForever() : nil)
)
.font(.footnote)
.foregroundColor(Color("ColorError"))
.position(x: 100, y: 100)
You just need to toggle back to .default animation instead of nil
.animation(carError ? Animation.easeIn(duration: 0.7).repeatForever() : .default)