I have a button implemented like so:
struct CircleButtonView: View {
init(_ content: Image, tutorial: Bool = false, _ action: (() -> ())? = nil) {
self.content = content
self.repetitions = tutorial ? 6.0 : 0.0
self.action = action
}
let content: Image
var repetitions: CGFloat
let action: (() -> ())?
var body: some View {
Button(action: {
action?()
}) {
Circle()
.fill(.white)
.shadow(color: Color(hex: 0x000000, alpha: 0.1), radius: 10, x: 0, y: 10)
.overlay(
GeometryReader { proxy in
content
.resizable()
.aspectRatio(contentMode: .fit) // 1:1
.frame(width: proxy.size.width * 0.4)
.position(x: proxy.size.width / 2, y: proxy.size.height / 2)
}
)
.aspectRatio(1 / 1, contentMode: .fit)
}
.shake(repetitions: repetitions)
.animation(repetitions != 0 ? .linear(duration: 1).delay(2).repeatForever(autoreverses: false) : .default, value: repetitions)
}
}
Via the tutorial bool i want to manipulate whether the button animates (an initial shake animation used to explain the button, if the user clicked it once i remove the animation).
This works nicely. However i have to use a "hack", which i dont like and will describe now.
I call the button in some parent view like so:
CircleButtonView(Image("flash.selected"), tutorial: <condition for tutorial> && appeared) {
// vm.boost()
}
.onAppear {
appeared = true
}
As you can see, despite my condition i also have to check wheter the button has appeared. If i would not do that, the animation would be lost ... (this is, as the init block runs and the view did not appear yet, any future calls, where the view appeared, do work perfectly)
As this does not feel right, is this the correct way ?
In order to animate, SwiftUI needs to observe a before state of the view and an after state. As you've seen, SwiftUI needs to see the repetitions value change from 0 to 6 in order to perform the animation. Processing the .onAppear { } from the user of CircleButtonView is the wrong place. Put that code inside of CircleButtonView.
Make repetitions into an #State private var. Don't set it in init().
#State private var repetitions = 0
In init(), store tutorial in a local let:
self.tutorial = tutorial
Then add this .onAppear to your Button:
}
.shake(repetitions: repetitions)
.animation(repetitions != 0 ? .linear(duration: 1).delay(2).repeatForever(autoreverses: false) : .default, value: repetitions)
.onAppear {
repetitions = tutorial ? 6.0 : 0.0
}
Related
I've been looking to add a loading indicator to my project, and found a really cool animation here. To make it easier to use, I wanted to incorporate it into a view modifier to put it on top of the current view. However, when I do so, it doesn't animate when I first press the button. I have played around with it a little, and my hypothesis is that the View Modifier doesn't pass the initial isAnimating = false, so only passes it isAnimating = true when the button is pressed. Because the ArcsAnimationView doesn't get the false value initially, it doesn't actually animate anything and just shows the static arcs. However, if I press the button a second time afterwards, it seems to be initialized and the view properly animates as desired.
Is there a better way to structure my code to avoid this issue? Am I missing something key? Any help is greatly appreciated.
Below is the complete code:
import SwiftUI
struct ArcsAnimationView: View {
#Binding var isAnimating: Bool
let count: UInt = 4
let width: CGFloat = 5
let spacing: CGFloat = 2
init(isAnimating: Binding<Bool>) {
self._isAnimating = isAnimating
}
var body: some View {
GeometryReader { geometry in
ForEach(0..<Int(count)) { index in
item(forIndex: index, in: geometry.size)
// the rotation below is what is animated ...
// I think the problem is that it just starts at .degrees(360), instead of
// .degrees(0) as expected, where it is then animated to .degrees(360)
.rotationEffect(isAnimating ? .degrees(360) : .degrees(0))
.animation(
Animation.default
.speed(Double.random(in: 0.05...0.25))
.repeatCount(isAnimating ? .max : 1, autoreverses: false)
, value: isAnimating
)
.foregroundColor(Color(hex: AppColors.darkBlue1.rawValue))
}
}
.aspectRatio(contentMode: .fit)
}
private func item(forIndex index: Int, in geometrySize: CGSize) -> some View {
Group { () -> Path in
var p = Path()
p.addArc(center: CGPoint(x: geometrySize.width/2, y: geometrySize.height/2),
radius: geometrySize.width/2 - width/2 - CGFloat(index) * (width + spacing),
startAngle: .degrees(0),
endAngle: .degrees(Double(Int.random(in: 120...300))),
clockwise: true)
return p.strokedPath(.init(lineWidth: width))
}
.frame(width: geometrySize.width, height: geometrySize.height)
}
}
struct ArcsAnimationModifier: ViewModifier {
#Binding var isAnimating: Bool
func body(content: Content) -> some View {
ZStack {
if isAnimating {
ArcsAnimationView(isAnimating: _isAnimating)
.frame(width: 150)
}
content
.disabled(isAnimating)
}
}
}
extension View {
func loadingAnimation(isAnimating: Binding<Bool>) -> some View {
self.modifier(ArcsAnimationModifier(isAnimating: isAnimating))
}
}
Here is where I actually call the function:
struct AnimationView: View {
#State var isAnimating = false
var body: some View {
VStack {
Button(action: {
self.isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
self.isAnimating = false
}
}, label: {
Text("show animation")
})
}
.loadingAnimation(isAnimating: $isAnimating)
}
}
Note: I am fairly certain the issue is with View Modifier since if I call ArcsAnimationView as a regular view in AnimationView, it works as expected.
I get there to see some implementation, but I think others would prefer a simple base to start from.
here my 2 cents to show how to write an AnimatableModifier that can be used on multiple objects cleaning up ".animation" in code.
struct ContentView: View {
#State private var hideWhilelUpdating = false
var body: some View {
Image(systemName: "tshirt.fill")
.modifier(SmoothHideAndShow(hide: hideWhilelUpdating))
Text("Some contents to show...")
.modifier(SmoothHideAndShow(hide: hideWhilelUpdating))
Button( "hide and show smootly") {
hideWhilelUpdating.toggle()
}
.padding(60)
}
}
struct SmoothHideAndShow: AnimatableModifier {
var hide: Bool
var animatableData: CGFloat {
get { CGFloat(hide ? 0 : 1) }
set { hide = newValue == 0 }
}
func body(content: Content) -> some View {
content
.opacity(hide ? 0.2 : 1)
.animation(.easeIn(duration: 1), value: hide)
}
}
when pressing button, our bool will trigger animation that fades in and out our text.
I use it during network calls (omitted for clarity... and replaced with button) to hide values under remote update. When network returns, I toggle boolean.
SwiftUI has the default slide transition .transition(.slide). It works well, but only halfway. I mean, I have to slide my views and then slide them back (I need the animation backwards). Since it is not possible to set the direction of the slide transition, I implemented a custom transition. The implementation is simple enough and works well. But I can't get the view size to calculate the start and end offset points.
extension AnyTransition {
static func slide(direction: SlideModifier.Direction) -> AnyTransition {
.asymmetric(insertion: .modifier(active: SlideModifier(positiveOffset: true, direction: direction), identity: SlideModifier(positiveOffset: nil, direction: direction)),
removal: .modifier(active: SlideModifier(positiveOffset: false, direction: direction), identity: SlideModifier(positiveOffset: nil, direction: direction)))
}
}
internal struct SlideModifier: ViewModifier {
let positiveOffset: Bool?
let direction: Direction
let offsetSize: CGFloat = 200 // How can I get this value programmatically?
enum Direction {
case leading, top, trailing, bottom
}
func body(content: Content) -> some View {
switch direction {
case .leading:
return content.offset(x: positiveOffset == nil ? 0 : (positiveOffset! ? offsetSize : -offsetSize))
case .top:
return content.offset(y: positiveOffset == nil ? 0 : (positiveOffset! ? offsetSize : -offsetSize))
case .trailing:
return content.offset(x: positiveOffset == nil ? 0 : (positiveOffset! ? -offsetSize : offsetSize))
case .bottom:
return content.offset(y: positiveOffset == nil ? 0 : (positiveOffset! ? -offsetSize : offsetSize))
}
}
}
I want to define the offset size that is equal to the view size. My experiments have shown that this is how the standard slide transition works. I tried using GeometryReader and PreferenceKey to get the size. Maybe I did something wrong, but it didn't work.
Here's a simple example of using my custom transition.
struct ContentView: View {
#State private var isShowingRed = false
var body: some View {
ZStack {
if isShowingRed {
Color.red
.frame(width: 200, height: 200)
.transition(.slide(direction: .trailing))
} else {
Color.blue
.frame(width: 200, height: 200)
.transition(.slide(direction: .leading))
}
}
.onTapGesture {
withAnimation {
isShowingRed.toggle()
}
}
}
}
There are a few ways to get the size of a view. In your case I'm assuming you're looking for the screen size. Here are the methods to get that. Please bear in mind I don't have my IDE open to fully test this, there may be syntax issues in it. I'll update my answer later when I have my IDE open.
Hot Tip
Get used to using an in-line condition, it'll help tremendously when dealing with animations and views in Swift UI
someBool ? varIfTrue : varIfFalse
someValue == someTest ? varIfTrue : varIfFalse
//(boolCondition) ? (Value if True) : (Value If False)
//You can even chain them together, but be careful to keep it
//organized if you do this.
someBool ? someBool2 ? val1 : val2 : someBool2 ? val3 : val4
Raw Screen Size
Nice Extension Provided Here
var width = UIScreen.main.bounds.size.width
var height = UIScreen.main.bounds.size.height
Geometry Reader
This one is a bit more confusing because it will get the VIEW that it is in. It also has a property that you can set to ignore safe areas. If you use this method you'll get different responses based on the view.
//Parent Views Matter with GeometryReader
var body: some View {
GeometryReader { geometry in
//Use the geometry, full screen size.
}.ignoresSafeAreaAndEdges(.all)
}
var body: someView {
VStack {
GeometryReader { geometry in
//This will have only the size of the parent view.
// Ex: 100x100 square.
}
}.frame(width: 100, height: 100)
}
Animating Backwards
The way that you're animating works, however doing it this way will give you a "Rewind" type effect.
var body: some View {
ZStack {
if isShowingRed {
Color.red
.frame(width: 200, height: 200)
.offset(isShowingRed ? 0 : 100)
.animation(.spring()) //You can also use .default
} else {
Color.blue
.frame(width: 200, height: 200)
.offset(isShowingRed ? 100 : 0)
.animation(.spring()) //You can also use .default
}
}
.onTapGesture {
withAnimation {
isShowingRed.toggle()
}
}
}
I have a TabView thats using the swiftUI 2.0 PageTabViewStyle. Is there any way to disable the swipe to change pages?
I have a search bar in my first tab view, but if a user is typing, I don't want to give the ability to change they are on, I basically want it to be locked on to that screen until said function is done.
Here's a gif showing the difference, I'm looking to disable tab changing when it's full screen in the gif.
https://imgur.com/GrqcGCI
Try something like the following (tested with some stub code). The idea is to block tab view drag gesture when some condition (in you case start editing) happens
#State var isSearching = false
// ... other code
TabView {
// ... your code here
Your_View()
.gesture(isSearching ? DragGesture() : nil) // blocks TabView gesture !!
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
I tried Asperis's solution, but I still couldn't disable the swiping, and adding disabled to true didn't work since I want the child views to be interactive. The solution that worked for me was using Majid's (https://swiftwithmajid.com/2019/12/25/building-pager-view-in-swiftui/) custom Pager View and adding a conditional like Asperi's solution.
Majid's PagerView with conditional:
import SwiftUI
struct PagerView<Content: View>: View {
let pageCount: Int
#Binding var canDrag: Bool
#Binding var currentIndex: Int
let content: Content
init(pageCount: Int, canDrag: Binding<Bool>, currentIndex: Binding<Int>, #ViewBuilder content: () -> Content) {
self.pageCount = pageCount
self._canDrag = canDrag
self._currentIndex = currentIndex
self.content = content()
}
#GestureState private var translation: CGFloat = 0
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
self.content.frame(width: geometry.size.width)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * geometry.size.width)
.offset(x: self.translation)
.animation(.interactiveSpring(), value: currentIndex)
.animation(.interactiveSpring(), value: translation)
.gesture(!canDrag ? nil : // <- here
DragGesture()
.updating(self.$translation) { value, state, _ in
state = value.translation.width
}
.onEnded { value in
let offset = value.translation.width / geometry.size.width
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
}
}
ContentView:
import SwiftUI
struct ContentView: View {
#State private var currentPage = 0
#State var canDrag: Bool = true
var body: some View {
PagerView(pageCount: 3, canDrag: $canDrag, currentIndex: $currentPage) {
VStack {
Color.blue
Button {
canDrag.toggle()
} label: {
Text("Toogle drag")
}
}
VStack {
Color.red
Button {
canDrag.toggle()
} label: {
Text("Toogle drag")
}
}
VStack {
Color.green
Button {
canDrag.toggle()
} label: {
Text("Toogle drag")
}
}
}
}
}
Ok I think it is possible to block at least 99% swipe gesture if not 100% by using this steps:
and 2.
Add .gesture(DragGesture()) to each page
Add .tabViewStyle(.page(indexDisplayMode: .never))
SwiftUI.TabView(selection: $viewModel.selection) {
ForEach(pages.indices, id: \.self) { index in
pages[index]
.tag(index)
.gesture(DragGesture())
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
Add .highPriorityGesture(DragGesture()) to all remaining views images, buttons that still enable to drag and swipe pages
You can also in 1. use highPriorityGesture but it completely blocks drags on each pages, but I need them in some pages to rotate something
For anyone trying to figure this out, I managed to do this by setting the TabView state to disabled.
TabView(selection: $currentIndex.animation()) {
Items()
}.disabled(true)
Edit: as mentioned in the comments this will disable everything within the TabView as well
I'm trying to change the colour of my animation based on the state of something. The colour change works but it animates the previous (orange) colour with it. I can't quite work out why both colours are showing. Any ideas?
struct PulsatingView: View {
var state = 1
func colourToShow() -> Color {
switch state {
case 0:
return Color.red
case 1:
return Color.orange
case 2:
return Color.green
default:
return Color.orange
}
}
#State var animate = false
var body: some View {
VStack {
ZStack {
Circle().fill(colourToShow().opacity(0.25)).frame(width: 40, height: 40).scaleEffect(self.animate ? 1 : 0)
Circle().fill(colourToShow().opacity(0.35)).frame(width: 30, height: 30).scaleEffect(self.animate ? 1 : 0)
Circle().fill(colourToShow().opacity(0.45)).frame(width: 15, height: 15).scaleEffect(self.animate ? 1 : 0)
Circle().fill(colourToShow()).frame(width: 6.25, height: 6.25)
}
.onAppear { self.animate.toggle() }
.animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true))
}
}
}
You change the color, but animating view, which is set up forever is not changed - it remains as set and continues as specified - forever. So it needs to re-set the animation.
Please find below a working full module demo code (tested with Xcode 11.2 / iOS 13.2). The idea is to use ObservableObject view model as it allows and refresh view and perform some actions on receive. So receiving color changes it is possible to reset and view color and animation.
Updated for Xcode 13.3 / iOS 15.4
Main part is:
}
.onAppear { self.animate = true }
.animation(animate ? Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true) : .default, value: animate)
.onChange(of: viewModel.colorIndex) { _ in
self.animate = false // << here !!
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.animate = true // << here !!
}
}
Complete findings and code is here
SwiftUI animations are typically driven by state, which is great, but sometimes you really want to trigger a temporary (often reversible) animation in response to some event. For example, I want to temporarily increase the size of a button when a it is tapped (both the increase and decrease in size should happen as a single animation when the button is released), but I haven't been able to figure this out.
It can sort of be hacked together with transitions I think, but not very nicely. Also, if I make an animation that uses autoreverse, it will increase the size, decrease it and then jump back to the increased state.
That is something I have been into as well.
So far my solution depends on applying GeometryEffect modifier and misusing the fact that its method effectValue is called continuously during some animation. So the desired effect is actually a transformation of interpolated values from 0..1 that has the main effect in 0.5 and no effect at 0 or 1
It works great, it is applicable to all views not just buttons, no need to depend on touch events or button styles, but still sort of seems to me as a hack.
Example with random rotation and scale effect:
Code sample:
struct ButtonEffect: GeometryEffect {
var offset: Double // 0...1
var animatableData: Double {
get { offset }
set { offset = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let effectValue = abs(sin(offset*Double.pi))
let scaleFactor = 1+0.2*effectValue
let affineTransform = CGAffineTransform(rotationAngle: CGFloat(effectValue)).translatedBy(x: -size.width/2, y: -size.height/2).scaledBy(x: CGFloat(scaleFactor), y: CGFloat(scaleFactor))
return ProjectionTransform(affineTransform)
}
}
struct ButtonActionView: View {
#State var animOffset: Double = 0
var body: some View {
Button(action:{
withAnimation(.spring()) {
self.animOffset += 1
}
})
{
Text("Press ME")
.padding()
}
.background(Color.yellow)
.modifier(ButtonEffect(offset: animOffset))
}
}
You can use a #State variable tied to a longPressAction():
Code updated for Beta 5:
struct ContentView: View {
var body: some View {
HStack {
Spacer()
MyButton(label: "Button 1")
Spacer()
MyButton(label: "Button 2")
Spacer()
MyButton(label: "Button 3")
Spacer()
}
}
}
struct MyButton: View {
let label: String
#State private var pressed = false
var body: some View {
return Text(label)
.font(.title)
.foregroundColor(.white)
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).foregroundColor(.green))
.scaleEffect(self.pressed ? 1.2 : 1.0)
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { pressing in
withAnimation(.easeInOut(duration: 0.2)) {
self.pressed = pressing
}
}, perform: { })
}
}
I believe this is what you're after. (this is how I solved this problem)
Based on dfd's link in i came up with this, which is not dependent on any #State variable. You simply just implement your own button style.
No need for Timers, #Binding, #State or other complex workarounds.
import SwiftUI
struct MyCustomPressButton: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(10)
.cornerRadius(10)
.scaleEffect(configuration.isPressed ? 0.8 : 1.0)
}
}
struct Play: View {
var body: some View {
Button("Tap") {
}.buttonStyle(MyCustomPressButton())
.animation(.easeIn(duration: 0.2))
}
}
struct Play_Previews: PreviewProvider {
static var previews: some View {
Play()
}
}
There is no getting around the need to update via state in SwiftUI. You need to have some property that is only true for a short time that then toggles back.
The following animates from small to large and back.
struct ViewPlayground: View {
#State var enlargeIt = false
var body: some View {
Button("Event!") {
withAnimation {
self.enlargeIt = true
}
}
.background(Momentary(doIt: self.$enlargeIt))
.scaleEffect(self.enlargeIt ? 2.0 : 1.0)
}
}
struct Momentary: View {
#Binding var doIt: Bool
var delay: TimeInterval = 0.35
var body: some View {
Group {
if self.doIt {
ZStack { Spacer() }
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + self.delay) {
withAnimation {
self.doIt = false
}
}
}
}
}
}
}
Unfortunately delay was necessary to get the animation to occur when setting self.enlargeIt = true. Without that it only animates back down. Not sure if that's a bug in Beta 4 or not.