SwiftUI:如何一次播放乒乓动画?正确播放动画的方式?

时间:2019-11-19 16:42:37

标签: swift swiftui

我需要的样品:

введите сюда описание изображения

由于.onAnimationCompleted { // Some work... }的缺失,这确实是个问题。

通常,我需要具有以下特征的解决方案:

  1. 一次播放乒乓动画的最简短,优雅的方式。不是无限的!
  2. 使代码可重复使用。例如,将其设置为ViewModifier。
  3. 有一种从外部调用动画的方法

我的代码:

import SwiftUI
import Combine

struct ContentView: View {
    @State var descr: String = ""
    @State var onError = PassthroughSubject<Void, Never>()

    var body: some View {
        VStack {
            BlurredTextField(title: "Description", text: $descr, onError: $onError)
            Button("Commit") {
                if self.descr.isEmpty {
                    self.onError.send()
                }
            }
        }
    }
}

struct BlurredTextField: View {
    let title: String
    @Binding var text: String
    @Binding var onError: PassthroughSubject<Void, Never>
    @State private var anim: Bool = false
    @State private var timer: Timer?
    @State private var cancellables: Set<AnyCancellable> = Set()
    private let animationDiration: Double = 1

    var body: some View {
        TextField(title, text: $text)
            .blur(radius: anim ? 10 : 0)
            .animation(.easeInOut(duration: animationDiration))
            .onAppear {
                self.onError
                    .sink(receiveValue: self.toggleError)
                    .store(in: &self.cancellables)
        }
    }

    func toggleError() {
        timer?.invalidate()// no blinking hack
        anim = true
        timer = Timer.scheduledTimer(withTimeInterval: animationDiration, repeats: false) { _ in
            self.anim = false
        }
    }
}

2 个答案:

答案 0 :(得分:4)

这个怎么样?不错的呼叫站点,逻辑封装在主视图之外,可选的闪烁持续时间。您只需要提供PassthroughSubject,并在需要眨眼时致电.send()

Blink demo

import SwiftUI
import Combine

struct ContentView: View {
    let blinkPublisher = PassthroughSubject<Void, Never>()

    var body: some View {
        VStack(spacing: 10) {
            Button("Blink") {
                self.blinkPublisher.send()
            }
            Text("Hi")
                .addOpacityBlinker(subscribedTo: blinkPublisher)
            Text("Hi")
                .addOpacityBlinker(subscribedTo: blinkPublisher, duration: 0.5)
        }
    }
}

这是您要调用的视图扩展

extension View {
    // the generic constraints here tell the compiler to accept any publisher
    //   that sends outputs no value and never errors
    // this could be a PassthroughSubject like above, or we could even set up a TimerPublisher
    //   that publishes on an interval, if we wanted a looping animation
    //   (we'd have to map it's output to Void first)
    func addOpacityBlinker<T: Publisher>(subscribedTo publisher: T, duration: Double = 1)
        -> some View where T.Output == Void, T.Failure == Never {

            // here I take whatever publisher we got and type erase it to AnyPublisher
            //   that just simplifies the type so I don't have to add extra generics below
            self.modifier(OpacityBlinker(subscribedTo: publisher.eraseToAnyPublisher(),
                                         duration: duration))
    }
}

这实际上是发生魔法的ViewModifier

// you could call the .modifier(OpacityBlinker(...)) on your view directly,
//   but I like the View extension method, as it just feels cleaner to me
struct OpacityBlinker: ViewModifier {
    // this is just here to switch on and off, animating the blur on and off
    @State private var isBlurred = false
    var publisher: AnyPublisher<Void, Never>
    // The total time it takes to blur and unblur
    var duration: Double

    // this initializer is not necessary, but allows us to specify a default value for duration,
    //   and the call side looks nicer with the 'subscribedTo' label
    init(subscribedTo publisher: AnyPublisher<Void, Never>, duration: Double = 1) {
        self.publisher = publisher
        self.duration = duration
    }

    func body(content: Content) -> some View {
        content
            .blur(radius: isBlurred ? 10 : 0)
            // This basically subscribes to the publisher, and triggers the closure
            //   whenever the publisher fires
            .onReceive(publisher) { _ in
                // perform the first half of the animation by changing isBlurred to true
                // this takes place over half the duration
                withAnimation(.linear(duration: self.duration / 2)) {
                    self.isBlurred = true
                    // schedule isBlurred to return to false after half the duration
                    // this means that the end state will return to an unblurred view
                    DispatchQueue.main.asyncAfter(deadline: .now() + self.duration / 2) {
                        withAnimation(.linear(duration: self.duration / 2)) {
                            self.isBlurred = false
                        }
                    }
                }
        }
    }
}

答案 1 :(得分:0)

John的回答绝对不错,可以帮助我准确找到所需的内容。我扩展了答案,以允许将任何视图修改一次都“刷新”并返回。

示例结果:

enter image description here

示例代码:

struct FlashTestView : View {
    
    let flashPublisher1 = PassthroughSubject<Void, Never>()
    let flashPublisher2 = PassthroughSubject<Void, Never>()
    
    var body: some View {
        VStack {
            Text("Scale Out & In")
                .padding(20)
                .background(Color.white)
                .flash(on: flashPublisher1) { (view, isFlashing) in
                    view
                        .scaleEffect(isFlashing ? 1.5 : 1)
                }
                .onTapGesture {
                    flashPublisher1.send()
                }
            
            Divider()

            Text("Flash Text & Background")
                .padding(20)
                // Connivence view extension for background and text color
                .flash(
                    on: flashPublisher2,
                    originalBackgroundColor: .white,
                    flashBackgroundColor: .blue,
                    originalForegroundColor: .primary,
                    flashForegroundColor: .white)
                .onTapGesture {
                    flashPublisher2.send()
                }
        }
        
    }
}

这是约翰回答中的修改代码。

extension View {
    
    /// Listens to a signal from a publisher and temporarily applies styles via the content callback.
    /// - Parameters:
    ///   - publisher: The publisher that sends a signal to apply the temp styles.
    ///   - animation: The animation used to change properties.
    ///   - delayBack: How long, in seconds, after flashing starts should the styles start to revert. Typically this is the same duration as the animation.
    ///   - content: A closure with two arguments to allow customizing the view when flashing. Should return the modified view back out.
    ///   - view: The view being modified.
    ///   - isFlashing: A boolean to indicate if a flash should be applied. Example: `view.scaleEffect(isFlashing ? 1.5 : 1)`
    /// - Returns: A view that applies its flash changes when it receives its signal.
    func flash<T: Publisher, InnerContent: View>(
        on publisher: T,
        animation: Animation = .easeInOut(duration: 0.3),
        delayBack: Double = 0.3,
        @ViewBuilder content: @escaping (_ view: Self, _ isFlashing: Bool) -> InnerContent)
    -> some View where T.Output == Void, T.Failure == Never {
        // here I take whatever publisher we got and type erase it to AnyPublisher
        // that just simplifies the type so I don't have to add extra generics below
        self.modifier(
            FlashStyleModifier(
                publisher: publisher.eraseToAnyPublisher(),
                animation: animation,
                delayBack: delayBack,
                content: { (view, isFlashing) in
                    return content(self, isFlashing)
                }))
    }
    
    /// A helper function built on top of the method above.
    /// Listens to a signal from a publisher and temporarily animates to a background color and text color.
    /// - Parameters:
    ///   - publisher: The publisher that sends a signal to apply the temp styles.
    ///   - animation: The animation used to change properties.
    ///   - delayBack: How long, in seconds, after flashing starts should the styles start to revert. Typically this is the same duration as the animation.
    ///   - originalBackgroundColor: The normal state background color
    ///   - flashBackgroundColor: The background color when flashing.
    ///   - originalForegroundColor: The normal text color.
    ///   - flashForegroundColor: The text color when flashing.
    /// - Returns: A view that flashes it's background and text color.
    func flash<T: Publisher>(
        on publisher: T,
        animation: Animation = .easeInOut(duration: 0.3),
        delayBack: Double = 0.3,
        originalBackgroundColor: Color,
        flashBackgroundColor: Color,
        originalForegroundColor: Color,
        flashForegroundColor: Color)
    -> some View where T.Output == Void, T.Failure == Never {
        // here I take whatever publisher we got and type erase it to AnyPublisher
        // that just simplifies the type so I don't have to add extra generics below
        self.flash(on: publisher, animation: animation) { view, isFlashing in
            return view
                // Need to apply arbitrary foreground color, but it's not animatable but need for colorMultiply to work.
                .foregroundColor(.white)
                // colorMultiply is animatable, so make foregroundColor flash happen here
                .colorMultiply(isFlashing ? flashForegroundColor : originalForegroundColor)
                // Apply background AFTER colorMultiply so that background color is not unexpectedly modified
                .background(isFlashing ? flashBackgroundColor : originalBackgroundColor)
        }
    }
}

/// A view modifier that temporarily applies styles based on a signal from a publisher.
struct FlashStyleModifier<InnerContent: View>: ViewModifier {
    
    @State
    private var isFlashing = false
    
    let publisher: AnyPublisher<Void, Never>
    
    let animation: Animation
    
    let delayBack: Double
    
    let content: (_ view: Content, _ isFlashing: Bool) -> InnerContent

    func body(content: Content) -> some View {
        self.content(content, isFlashing)
            .onReceive(publisher) { _ in
                withAnimation(animation) {
                    self.isFlashing = true
                }
                
                DispatchQueue.main.asyncAfter(deadline: .now() + delayBack) {
                    withAnimation(animation) {
                        self.isFlashing = false
                    }
                }
                
            }
    }
}