I have my LottieView
struct LottieView: UIViewRepresentable {
var name: String
var loopMode: LottieLoopMode = .loop
var contentMode: UIView.ContentMode = .scaleAspectFit
var paused: Bool = false
var shouldPlay: Bool = true
var animationView = AnimationView()
func makeUIView(context: UIViewRepresentableContext<LottieView>) -> UIView {
let view = UIView(frame: .zero)
animationView.animation = Animation.named(name)
animationView.contentMode = contentMode
animationView.loopMode = loopMode
animationView.backgroundBehavior = .pauseAndRestore
if shouldPlay {
animationView.play()
}
animationView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.heightAnchor.constraint(equalTo: view.heightAnchor),
animationView.widthAnchor.constraint(equalTo: view.widthAnchor)
])
return view
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<LottieView>) {
if shouldPlay {
context.coordinator.parent.animationView.play { finished in
if context.coordinator.parent.animationView.loopMode == .playOnce && finished {
context.coordinator.parent.animationView.play()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
context.coordinator.parent.animationView.pause()
}
}
}
} else {
context.coordinator.parent.animationView.pause()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject {
var parent: LottieView
init(_ parent: LottieView) {
self.parent = parent
}
}
}
which animation's start depends on it's property shouldPlay passed in view's initializer.
I use LottieView in custom AddToCartButton structure:
struct AddToCartButton: View {
let onAdd: () -> Void
#State private var shouldPresentAddAnimation: Bool = false
var body: some View {
Button {
withAnimation {
shouldPresentAddAnimation = true
onAdd()
}
} label: {
HStack(spacing: 0) {
LottieView(name: "add_to_cart_2",
loopMode: .playOnce,
contentMode: .scaleAspectFit,
shouldPlay: shouldPresentAddAnimation)
.frame(minWidth: 50, maxHeight: 40)
Text("Add to Cart")
.font(.ssButton)
.foregroundColor(.ssWhite)
.padding(.all, 10)
}
.padding(.vertical, 5)
.background {
RoundedRectangle(cornerRadius: 5)
}
.fixedSize(horizontal: true, vertical: false)
}
}
}
Clicking on this button should play the animation once and then return to initial animation state thanks to the code in LottieView's updateUIView method.
I have my main view in which there are many AddToCartButton structures created like this:
AddToCartButton {
[some code...]
}
And the effect is that at first button click, it animates properly. On second different button click the second and the first one animation fires. When clicking on third button, all three buttons fire animations.
Sample in the photos attached:
Firstly, the initial state of buttons:
Secondly, after one button click:
Finally, after second button click (two of them fire animation):
What I want is that only the button that is clicked fire it's animation.
I think the problem is that you don’t set shouldPresentAddAnimation back to false after animation is played. So every time you press the button the whole list is rendered and since you state property is true, it fires animation.
Related
I am triggering animation after unhiding the view unfortunately animation is not working unless I tap twice
struct ContentView: View {
#State var animate = false
#State var isViewHidden: Bool = true
var body: some View {
VStack {
ZStack {
Circle()
.fill(.blue).opacity(0.25).frame(width: 40, height: 40).offset(y: self.animate ? 0 : 60)
.hides(isViewHidden)
}
.animation((Animation.linear(duration: 1.5).repeatForever(autoreverses: true))
, value: self.animate ? 0 : 60)
Spacer()
Button("Tap here") {
self.isViewHidden = false
self.animate.toggle()
}
}
.padding()
}
}
extension View {
#ViewBuilder
func hides(_ isHidden: Bool) -> some View {
if isHidden {
hidden()
} else {
self
}
}
}
SwiftUI uses a before view and an after view to animate. You are introducing the view to animate at the same time you are updating self.animate, so Swift doesn't have a before view to use for the animation.
Change your View extension to this:
extension View {
#ViewBuilder
func hides(_ isHidden: Bool) -> some View {
self.opacity(isHidden ? 0 : 1)
}
}
This leaves the view onscreen at all times, but just hides it by making it invisible. That way, the view is there to animate from the start.
I am creating a loading indicator in SwiftUI that should always be centered in the top-level view of the view hierarchy (i.e centered in the whole screen in a fullscreen app). This would be easy in UIKit, but SwiftUI centres views relative to their parent view only and I am not able to get the positions of the parent views of the parent view.
Sadly my app is not fully SwiftUI based, so I cannot easily set properties on my root views that I could then access in my loading view - I need this view to be centered regardless of what the view hierarchy looks like (mixed UIKit - SwiftUI parent views). This is why answers like SwiftUI set position to centre of different view don't work for my use case, since in that example, you need to modify the view in which you want to centre your child view.
I have tried playing around with the .offset and .position functions of View, however, I couldn't get the correct inputs to always dynamically centre my loadingView regardless of screen size or regardless of what part of the whole screen rootView takes up.
Please find a minimal reproducible example of the problem below:
/// Loading view that should always be centered in the whole screen on the XY axis and should be the top view in the Z axis
struct CenteredLoadingView<RootView: View>: View {
private let rootView: RootView
init(rootView: RootView) {
self.rootView = rootView
}
var body: some View {
ZStack {
rootView
loadingView
}
// Ensure that `AnimatedLoadingView` is displayed above all other views, whose `zIndex` would be higher than `rootView`'s by default
.zIndex(.infinity)
}
private var loadingView: some View {
VStack {
Color.white
.frame(width: 48, height: 72)
Text("Loading")
.foregroundColor(.white)
}
.frame(width: 142, height: 142)
.background(Color.primary.opacity(0.7))
.cornerRadius(10)
}
}
View above which the loading view should be displayed:
struct CenterView: View {
var body: some View {
return VStack {
Color.gray
HStack {
CenteredLoadingView(rootView: list)
otherList
}
}
}
var list: some View {
List {
ForEach(1..<6) {
Text($0.description)
}
}
}
var otherList: some View {
List {
ForEach(6..<11) {
Text($0.description)
}
}
}
}
This is what the result looks like:
This is how the UI should look like:
I have tried modifying the body of CenteredLoadingView using a GeometryReader and .frame(in: .global) to get the global screen size, but what I've achieved is that now my loadingView is not visible at all.
var body: some View {
GeometryReader<AnyView> { geo in
let screen = geo.frame(in: .global)
let stack = ZStack {
self.rootView
self.loadingView
.position(x: screen.midX, y: screen.midY)
// Offset doesn't work either
//.offset(x: -screen.origin.x, y: -screen.origin.y)
}
// Ensure that `AnimatedLoadingView` is displayed above all other views, whose `zIndex` would be higher than `rootView`'s by default
.zIndex(.infinity)
return AnyView(stack)
}
}
Here is a demo of possible approach. The idea is to use injected UIView to access UIWindow and then show loading view as a top view of window's root viewcontroller view.
Tested with Xcode 12 / iOS 14 (but SwiftUI 1.0 compatible)
Note: animations, effects, etc. are possible but are out scope for simplicity
struct CenteredLoadingView<RootView: View>: View {
private let rootView: RootView
#Binding var isActive: Bool
init(rootView: RootView, isActive: Binding<Bool>) {
self.rootView = rootView
self._isActive = isActive
}
var body: some View {
rootView
.background(Activator(showLoading: $isActive))
}
struct Activator: UIViewRepresentable {
#Binding var showLoading: Bool
#State private var myWindow: UIWindow? = nil
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async {
self.myWindow = view.window
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
guard let holder = myWindow?.rootViewController?.view else { return }
if showLoading && context.coordinator.controller == nil {
context.coordinator.controller = UIHostingController(rootView: loadingView)
let view = context.coordinator.controller!.view
view?.backgroundColor = UIColor.black.withAlphaComponent(0.8)
view?.translatesAutoresizingMaskIntoConstraints = false
holder.addSubview(view!)
holder.isUserInteractionEnabled = false
view?.leadingAnchor.constraint(equalTo: holder.leadingAnchor).isActive = true
view?.trailingAnchor.constraint(equalTo: holder.trailingAnchor).isActive = true
view?.topAnchor.constraint(equalTo: holder.topAnchor).isActive = true
view?.bottomAnchor.constraint(equalTo: holder.bottomAnchor).isActive = true
} else if !showLoading {
context.coordinator.controller?.view.removeFromSuperview()
context.coordinator.controller = nil
holder.isUserInteractionEnabled = true
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator {
var controller: UIViewController? = nil
}
private var loadingView: some View {
VStack {
Color.white
.frame(width: 48, height: 72)
Text("Loading")
.foregroundColor(.white)
}
.frame(width: 142, height: 142)
.background(Color.primary.opacity(0.7))
.cornerRadius(10)
}
}
}
struct CenterView: View {
#State private var isLoading = false
var body: some View {
return VStack {
Color.gray
HStack {
CenteredLoadingView(rootView: list, isActive: $isLoading)
otherList
}
Button("Demo", action: load)
}
.onAppear(perform: load)
}
func load() {
self.isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.isLoading = false
}
}
var list: some View {
List {
ForEach(1..<6) {
Text($0.description)
}
}
}
var otherList: some View {
List {
ForEach(6..<11) {
Text($0.description)
}
}
}
}
I have a common fadeIn and fadeOut animation that I use for when views appear and disappear:
struct ActiveView: View {
#State var showCode: Bool = false
#State var opacity = 0.0
var body: some View {
if self.showCode {
Color.black.opacity(0.7)
.onAppear{
let animation = Animation.easeIn(duration: 0.5)
return withAnimation(animation) {
self.opacity = 1
}
}
.onDisappear{
let animation = Animation.easeOut(duration: 0.5)
return withAnimation(animation) {
self.opacity = 0
}
}
}
}
}
However, I want to use these same animations on other views, so I want it to be simple and reusable, like this:
if self.showCode {
Color.black.opacity(0.7)
.fadeAnimation()
}
How can I achieve this?
EDIT:
Trying to implement a View extension:
extension View {
func fadeAnimation(opacity: Binding<Double>) -> some View {
self.onAppear{
let animation = Animation.easeIn(duration: 0.5)
return withAnimation(animation) {
opacity = 1
}
}
.onDisappear{
let animation = Animation.easeOut(duration: 0.5)
return withAnimation(animation) {
opacity = 0
}
}
}
}
What you try to do is already present and named opacity transition, which is written in one modifier.
Here is a demo:
struct ActiveView: View {
#State var showCode: Bool = false
var body: some View {
ZStack {
if self.showCode {
Color.black.opacity(0.7)
.transition(AnyTransition.opacity.animation(.default))
}
Button("Demo") { self.showCode.toggle() }
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
The functionality that you are trying to implement is already part of the Animation and Transition modifiers from SwiftUI.
Therefore, you can add .transition modifier to any of your Views and it will animate its insertion and removal.
if self.showCode {
Color.black.opacity(0.7)
.transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.5)))
}
You can use a multitude of different transitions like .slide, .scale, .offset, etc. More information about transitions here.
You can even create custom transitions with different actions for insertion and removal. In your case, different animation curves.
extension AnyTransition {
static var fadeTransition: AnyTransition {
.asymmetric(
insertion: AnyTransition.opacity.animation(.easeIn(duration: 0.5)),
removal: AnyTransition.opacity.animation(.easeOut(duration: 0.5))
)
}
}
And use it like this:
if self.showCode {
Color.black.opacity(0.7)
.transition(.fadeTransition)
}
Hope this helps 😉!
I have a long press gesture that highlights another button. The code looks like the following:
#GestureState var highlight = false
var body: some View {
var longPress: some Gesture {
LongPressGesture(minimumDuration: 3)
.updating($highlight) { currentstate, gestureState, transaction in
gestureState = currentstate
transaction.animation = Animation.easeInOut(duration: 2.0)
}
}
Text("highlight!")
.gesture(longPress)
Button(...) { ... }
.accentColor(self.highlight ? .green : .none)
}
How do I make sure that the transitions from .none accent to .green accent and back are more smooth? At the moment it switches rather abruptly.
The .accentColor is not animatable modifier, however you can achieve the same effect (as soon as I understood) with the following approach.
struct TestLongPressGesture: View {
#GestureState var highlight = false
var body: some View {
var longPress: some Gesture {
LongPressGesture(minimumDuration: 3)
.updating($highlight) { currentstate, gestureState, transaction in
transaction.animation = Animation.easeInOut(duration: 2.0)
gestureState = currentstate
}
}
return VStack {
Text("highlight!")
.gesture(longPress)
Divider()
Button("Button") { }
.font(Font.largeTitle.bold())
.foregroundColor(.white)
.colorMultiply(self.highlight ? .green : .blue)
.animation(.easeInOut, value: highlight)
}
}
}
I have a TextField and some actionable elements like Button, Picker inside a view. I want to dismiss the keyboard when the use taps outside the TextField. Using the answers in this question, I achieved it. However the problem comes with other actionable items.
When I tap a Button, the action takes place but the keyboard is not dismissed. Same with a Toggle switch.
When I tap on one section of a SegmentedStyle Picker, the keyboard is dimissed but the picker selection doesn't change.
Here is my code.
struct SampleView: View {
#State var selected = 0
#State var textFieldValue = ""
var body: some View {
VStack(spacing: 16) {
TextField("Enter your name", text: $textFieldValue)
.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.background(Color(UIColor.secondarySystemFill))
.cornerRadius(4)
Picker(selection: $selected, label: Text(""), content: {
Text("Word").tag(0)
Text("Phrase").tag(1)
Text("Sentence").tag(2)
}).pickerStyle(SegmentedPickerStyle())
Button(action: {
self.textFieldValue = "button tapped"
}, label: {
Text("Tap to change text")
})
}.padding()
.onTapGesture(perform: UIApplication.dismissKeyboard)
// .gesture(TapGesture().onEnded { _ in UIApplication.dismissKeyboard()})
}
}
public extension UIApplication {
static func dismissKeyboard() {
let keyWindow = shared.connectedScenes
.filter({$0.activationState == .foregroundActive})
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?.windows
.filter({$0.isKeyWindow}).first
keyWindow?.endEditing(true)
}
}
As you can see in the code, I tried both options to get the tap gesture and nothing worked.
You can create an extension on View like so
extension View {
func endTextEditing() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
}
and use it for the Views you want to dismiss the keyboard.
.onTapGesture {
self.endTextEditing()
}
I have just seen this solution in a recent raywenderlich tutorial so I assume it's currently the best solution.
Dismiss the keyboard by tapping anywhere (like others suggested) could lead to very hard to find bug (or unwanted behavior).
you loose default build-in TextField behaviors, like partial text
selection, copy, share etc.
onCommit is not called
I suggest you to think about gesture masking based on the editing state of your fields
/// Attaches `gesture` to `self` such that it has lower precedence
/// than gestures defined by `self`.
public func gesture<T>(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture
this help us to write
.gesture(TapGesture().onEnded({
UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
}), including: (editingFlag) ? .all : .none)
Tap on the modified View will dismiss the keyboard, but only if editingFlag == true. Don't apply it on TextField! Otherwise we are on the beginning of the story again :-)
This modifier will help us to solve the trouble with Picker but not with the Button. That is easy to solve while dismiss the keyboard from its own action handler. We don't have any other controls, so we almost done
Finally we have to find the solution for rest of the View, so tap anywhere (excluding our TextFields) dismiss the keyboard. Using ZStack filled with some transparent View is probably the easiest solution.
Let see all this in action (copy - paste - run in your Xcode simulator)
import SwiftUI
struct ContentView: View {
#State var selected = 0
#State var textFieldValue0 = ""
#State var textFieldValue1 = ""
#State var editingFlag = false
#State var message = ""
var body: some View {
ZStack {
// TODO: make it Color.clear istead yellow
Color.yellow.opacity(0.1).onTapGesture {
UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
}
VStack {
TextField("Salutation", text: $textFieldValue0, onEditingChanged: { editing in
self.editingFlag = editing
}, onCommit: {
self.onCommit(txt: "salutation commit")
})
.padding()
.background(Color(UIColor.secondarySystemFill))
.cornerRadius(4)
TextField("Welcome message", text: $textFieldValue1, onEditingChanged: { editing in
self.editingFlag = editing
}, onCommit: {
self.onCommit(txt: "message commit")
})
.padding()
.background(Color(UIColor.secondarySystemFill))
.cornerRadius(4)
Picker(selection: $selected, label: Text(""), content: {
Text("Word").tag(0)
Text("Phrase").tag(1)
Text("Sentence").tag(2)
})
.pickerStyle(SegmentedPickerStyle())
.gesture(TapGesture().onEnded({
UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
}), including: (editingFlag) ? .all : .none)
Button(action: {
self.textFieldValue0 = "Hi"
print("button pressed")
UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
}, label: {
Text("Tap to change salutation")
.padding()
.background(Color.yellow)
.cornerRadius(10)
})
Text(textFieldValue0)
Text(textFieldValue1)
Text(message).font(.largeTitle).foregroundColor(Color.red)
}
}
}
func onCommit(txt: String) {
print(txt)
self.message = [self.textFieldValue0, self.textFieldValue1].joined(separator: ", ").appending("!")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If you miss onCommit (it is not called while tap outside TextField), just add it to your TextField onEditingChanged (it mimics typing Return on keyboard)
TextField("Salutation", text: $textFieldValue0, onEditingChanged: { editing in
self.editingFlag = editing
if !editing {
self.onCommit(txt: "salutation")
}
}, onCommit: {
self.onCommit(txt: "salutation commit")
})
.padding()
.background(Color(UIColor.secondarySystemFill))
.cornerRadius(4)
I'd like to take Mark T.s Answer even further and add the entire function to an extension for View:
extension View {
func hideKeyboardWhenTappedAround() -> some View {
return self.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
}
}
Can then be called like:
var body: some View {
MyView()
// ...
.hideKeyboardWhenTappedAround()
// ...
}
#user3441734 is smart to enable the dismiss gesture only when needed. Rather than forcing every crevice of your forms to track state, you can:
Monitor UIWindow.keyboardWillShowNotification / willHide
Pass the current keyboard state via an EnvironmentKey set at the/a root view
Tested for iOS 14.5.
Attach dismiss gesture to the form
Form { }
.dismissKeyboardOnTap()
Setup monitor in root view
// Root view
.environment(\.keyboardIsShown, keyboardIsShown)
.onDisappear { dismantleKeyboarMonitors() }
.onAppear { setupKeyboardMonitors() }
// Monitors
#State private var keyboardIsShown = false
#State private var keyboardHideMonitor: AnyCancellable? = nil
#State private var keyboardShownMonitor: AnyCancellable? = nil
func setupKeyboardMonitors() {
keyboardShownMonitor = NotificationCenter.default
.publisher(for: UIWindow.keyboardWillShowNotification)
.sink { _ in if !keyboardIsShown { keyboardIsShown = true } }
keyboardHideMonitor = NotificationCenter.default
.publisher(for: UIWindow.keyboardWillHideNotification)
.sink { _ in if keyboardIsShown { keyboardIsShown = false } }
}
func dismantleKeyboarMonitors() {
keyboardHideMonitor?.cancel()
keyboardShownMonitor?.cancel()
}
SwiftUI Gesture + Sugar
struct HideKeyboardGestureModifier: ViewModifier {
#Environment(\.keyboardIsShown) var keyboardIsShown
func body(content: Content) -> some View {
content
.gesture(TapGesture().onEnded {
UIApplication.shared.resignCurrentResponder()
}, including: keyboardIsShown ? .all : .none)
}
}
extension UIApplication {
func resignCurrentResponder() {
sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
}
extension View {
/// Assigns a tap gesture that dismisses the first responder only when the keyboard is visible to the KeyboardIsShown EnvironmentKey
func dismissKeyboardOnTap() -> some View {
modifier(HideKeyboardGestureModifier())
}
/// Shortcut to close in a function call
func resignCurrentResponder() {
UIApplication.shared.resignCurrentResponder()
}
}
EnvironmentKey
extension EnvironmentValues {
var keyboardIsShown: Bool {
get { return self[KeyboardIsShownEVK] }
set { self[KeyboardIsShownEVK] = newValue }
}
}
private struct KeyboardIsShownEVK: EnvironmentKey {
static let defaultValue: Bool = false
}
You can set .allowsHitTesting(false) to your Picker to ignore the tap on your VStack
Apply this to root view
.onTapGesture {
UIApplication.shared.endEditing()
}