SwiftUI: how to play ping-pong animation once? Correct way to play animation forward and backward? - swift

Sample of what I need:
.
As there is absent .onAnimationCompleted { // Some work... } its pretty problematic.
Generally I need the solution that will have a following characteristics:
Most short and elegant way of playing some ping-pong animation ONCE. Not infinite!
Make code reusable. As example - made it as ViewModifier.
To have a way to call animation externally
my code:
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
}
}
}

How about this? Nice call site, logic encapsulated away from your main view, optional blink duration. All you need to provide is the PassthroughSubject, and call .send() when you want the blink to happen.
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)
}
}
}
Here's the view extension you would call
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))
}
}
Here's the ViewModifier where the magic actually happens
// 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
}
}
}
}
}
}

John's answer is absolutely great and helped me get to exactly what I was looking for. I extended the answer to allow for any view modification to "flash" once and return.
Example Result:
Example Code:
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()
}
}
}
}
Here's the modified code from John's answer.
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
}
}
}
}
}

Related

SwiftUI: calling objectWillChange.send() not updating child view

I have a rather complicated set of views nested in views. When I trigger a button action, I pass along an optional block through my viewModel class which calls objectWillChange.send() on that viewModel and I know that it's being triggered because the other parts of my view are updating. One of the child views (which is observing that viewModel changes) doesn't update until I click on part of it (which changes viewModel.selectedIndex and triggers redraw so I know it's listening for published changes).
Why isn't the update triggering the child view (in this case PurchaseItemGrid) to redraw itself?
Here's where I setup the call to update...
struct RightSideView: View {
#ObservedObject var viewModel: TrenchesPurchases
var body: some View {
VStack {
...
PurchaseItemGrid(viewModel: viewModel) // <-- View not updating
Button {
viewModel.purchaseAction() {
viewModel.objectWillChange.send() // <-- Triggers redraw, reaches breakpoint here
}
} label: {
...
}
...
}
}
}
Here's where the optional is called (and I've not only visually confirmed this is happening as other parts of the view redraw, it also hits breakpoint here)...
class TrenchesPurchases: ObservableObject, CanPushCurrency {
// MARK: - Properties
#Published private var model = Purchases()
// MARK: - Properties: Computed
var selectedIndex: Int {
get { return model.selectedIndex }
set { model.selectedIndex = newValue }
}
var purchaseAction: BlockWithBlock {
{ complete in
...
complete?()
}
}
...
}
And here's the view that's not updating as expected...
struct PurchaseItemGrid: View {
#ObservedObject var viewModel: TrenchesPurchases
var body: some View {
VStack {
itemRow(indices: 0...3)
...
}
...
}
#ViewBuilder
func itemRow(indices range: ClosedRange<Int>) -> some View {
HStack {
ForEach(viewModel.purchaseItems[range], id: \.id) { item in
PurchaseItemView(item: item,
borderColor: viewModel.selectedIndex == item.id ? .green : Color(Colors.oliveGreen))
.onTapGesture { viewModel.selectedIndex = item.id }
}
}
}
}
Here's the code workingdog asked for...
struct Purchases {
// MARK: - Properties
var selectedIndex = 15
let items: [PurchaseItem] = buildCollectionOfItems()
// MARK: - Functions
// MARK: - Functions: Static
// TODO: Define Comments
static func buildCollectionOfItems() -> [PurchaseItem] {
return row0() + row1() + row2() + row3()
}
static func row0() -> [PurchaseItem] {
var items = [PurchaseItem]()
let grenade = Ammo(ammo: .grenade)
items.append(grenade)
let bullets = Ammo(ammo: .bullets)
items.append(bullets)
let infiniteBullets = Unlock(mode: .defense)
items.append(infiniteBullets)
let unlimitedInfantry = Unlock(mode: .offense)
items.append(unlimitedInfantry)
return items
}
static func row1() -> [PurchaseItem] {
var items = [PurchaseItem]()
for unit in UnitType.allCases {
let item = Unit(unit: unit)
items.append(item)
}
return items
}
static func row2() -> [PurchaseItem] {
var items = [PurchaseItem]()
let brits = NationItem(nation: .brits)
items.append(brits)
let turks = NationItem(nation: .turks)
items.append(turks)
let usa = NationItem(nation: .usa)
items.append(usa)
let insane = DifficultyItem(difficulty: .insane)
items.append(insane)
return items
}
static func row3() -> [PurchaseItem] {
var items = [PurchaseItem]()
let offenseLootBox = Random(mode: .offense)
items.append(offenseLootBox)
let defenseLootBox = Random(mode: .defense)
items.append(defenseLootBox)
let currency = Currency(isCheckin: false)
items.append(currency)
let checkIn = Currency(isCheckin: true)
items.append(checkIn)
return items
}
}
The issue I had was that the PurchaseItemGrid was noticing the observed item being published, but the change I was trying to trigger was in the PurchaseItemView which did not have an observed object.
I assumed that when the PurchaseItemGrid observed the change and was redrawn, the itemRow method would redraw a new collection of PurchaseItemView's that would then have their image updated to match the new state.
This was further compounded because the onTapGesture was triggering a redraw of the PurchaseItemView, and to be honest I'm still not sure how the PurchaseItemGrid could redraw itself while still using the same PurchaseItemView's in it's body; but it may have to do with how #ViewBuilder works and because the views were created in an entirely separate method.
So, long story short: make sure each view you want to update has some form of observer, don't rely on the parent's redraw to create new child views.

How set Position of window on the Desktop in SwiftUI?

How to set window coordinates in SwiftUI on MacOS Desktop? For example, should the window appear always in the center or always in the upper right corner?
Here is my version, however, I shift the code and close it, when I open it, it appears first in the old place, and then jumps to a new place.
import SwiftUI
let WIDTH: CGFloat = 400
let HEIGTH: CGFloat = 200
#main
struct ForVSCode_MacOSApp: App {
#State var window : NSWindow?
var body: some Scene {
WindowGroup {
ContentView(win: $window)
}
}
}
struct WindowAccessor: NSViewRepresentable{
#Binding var window: NSWindow?
func makeNSView(context: Context) -> some NSView {
let view = NSView()
let width = (NSScreen.main?.frame.width)!
let heigth = (NSScreen.main?.frame.height)!
let resWidth: CGFloat = (width / 2) - (WIDTH / 2)
let resHeigt: CGFloat = (heigth / 2) - (HEIGTH / 2)
DispatchQueue.main.async {
self.window = view.window
self.window?.setFrameOrigin(NSPoint(x: resWidth, y: resHeigt))
self.window?.setFrameAutosaveName("mainWindow")
self.window?.isReleasedWhenClosed = false
self.window?.makeKeyAndOrderFront(nil)
}
return view
}
func updateNSView(_ nsView: NSViewType, context: Context) {
}
}
and ContentView
import SwiftUI
struct ContentView: View {
#Binding var win: NSWindow?
var body: some View {
VStack {
Text("it finally works!")
}
.font(.largeTitle)
.frame(width: WIDTH, height: HEIGTH, alignment: .center)
.background(WindowAccessor(window: $win))
}
}
struct ContentView_Previews: PreviewProvider {
#Binding var win: NSWindow?
static var previews: some View {
ContentView(win: .constant(NSWindow()))
.frame(width: 250, height: 150, alignment: .center)
}
}
I do have the same issue in one of my projects and thought I will investigate a bit deeper and I found two approaches to control the window position.
So my first approach to influence the window position is by pre-defining the windows last position on screen.
Indirect control: Frame autosave name
When the first window of an app is opened, macOS will try to restore the last window position when it was last closed. To distinguish the different windows, each window has its own frameAutosaveName.
The windows frame is persisted automatically in a text format in the apps preferences (UserDefaults.standard) with the key derived from the frameAutosaveName: "NSWindow Frame <frameAutosaveName>" (see docs for saveFrame).
If you do not specify an ID in your WindowGroup, SwiftUI will derive the autosave name from your main views class name. The first three windows will have the following autosave names:
<ModuleName>.ContentView-1-AppWindow-1
<ModuleName>.ContentView-1-AppWindow-2
<ModuleName>.ContentView-1-AppWindow-3
By setting an ID for example WindowGroup(id: "main"), the following autosave names are used (again for the first three windows):
main-AppWindow-1
main-AppWindow-2
main-AppWindow-3
When you check in your apps preferences directory (where UserDefaults.standard is stored), you will see in the plist one entry:
NSWindow Frame main-AppWindow-1 1304 545 400 228 0 0 3008 1228
There are a lot of numbers to digest. The first 4 integers describe the windows frame (origin and size), the next 4 integers describe the screens frame.
There are a few things to keep in mind when manually setting those value:
macOS coordinate system has it origin (0,0) in the bottom left corner.
the windows height includes the window title bar (28px on macOS Monterey but may be different on other versions)
the screens height excludes the title bar
I don't have documentation on this format and used trial and error to gain knowledge about it...
So to fake the initial position in the center of the screen I used the following function which I run in the apps (or the ContentView) initializer. But keep in mind: with this method only the first window will be centered. All the following windows are going to be put down and right of the previous window.
func fakeWindowPositionPreferences() {
let main = NSScreen.main!
let screenWidth = main.frame.width
let screenHeightWithoutMenuBar = main.frame.height - 25 // menu bar
let visibleFrame = main.visibleFrame
let contentWidth = WIDTH
let contentHeight = HEIGHT + 28 // window title bar
let windowX = visibleFrame.midX - contentWidth/2
let windowY = visibleFrame.midY - contentHeight/2
let newFramePreference = "\(Int(windowX)) \(Int(windowY)) \(Int(contentWidth)) \(Int(contentHeight)) 0 0 \(Int(screenWidth)) \(Int(screenHeightWithoutMenuBar))"
UserDefaults.standard.set(newFramePreference, forKey: "NSWindow Frame main-AppWindow-1")
}
My second approach is by directly manipulating the underlying NSWindow similar to your WindowAccessor.
Direct control: Manipulating NSWindow
Your implementation of WindowAccessor has a specific flaw: Your block which is reading view.window to extract the NSWindow instance is run asynchronously: some time in the future (due to DispatchQueue.main.async).
This is why the window appears on screen on the SwiftUI configured position, then disappears again to finally move to your desired location. You need more control, which involves first monitoring the NSView to get informed as soon as possible when the window property is set and then monitoring the NSWindow instance to get to know when the view is becoming visible.
I'm using the following implementation of WindowAccessor. It takes a onChange callback closure which is called whenever window is changing. First it starts monitoring the NSViews window property to get informed when the view is added to a window. When this happened, it starts listening for NSWindow.willCloseNotification notifications to detect when the window is closing. At this point it will stop any monitoring to avoid leaking memory.
import SwiftUI
import Combine
struct WindowAccessor: NSViewRepresentable {
let onChange: (NSWindow?) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
context.coordinator.monitorView(view)
return view
}
func updateNSView(_ view: NSView, context: Context) {
}
func makeCoordinator() -> WindowMonitor {
WindowMonitor(onChange)
}
class WindowMonitor: NSObject {
private var cancellables = Set<AnyCancellable>()
private var onChange: (NSWindow?) -> Void
init(_ onChange: #escaping (NSWindow?) -> Void) {
self.onChange = onChange
}
/// This function uses KVO to observe the `window` property of `view` and calls `onChange()`
func monitorView(_ view: NSView) {
view.publisher(for: \.window)
.removeDuplicates()
.dropFirst()
.sink { [weak self] newWindow in
guard let self = self else { return }
self.onChange(newWindow)
if let newWindow = newWindow {
self.monitorClosing(of: newWindow)
}
}
.store(in: &cancellables)
}
/// This function uses notifications to track closing of `window`
private func monitorClosing(of window: NSWindow) {
NotificationCenter.default
.publisher(for: NSWindow.willCloseNotification, object: window)
.sink { [weak self] notification in
guard let self = self else { return }
self.onChange(nil)
self.cancellables.removeAll()
}
.store(in: &cancellables)
}
}
}
This implementation can then be used to get a handle to NSWindow as soon as possible. The issue we still face: we don't have full control of the window. We are just monitoring what happens and can interact with the NSWindow instance. This means: we can set the position, but we don't know exactly at which instant this should happen. E.g. setting the windows frame directly after the view has been added to the window, will have no impact as SwiftUI is first doing layout calculations to decide afterwards where it will place the window.
After some fiddling around, I started tracking the NSWindow.isVisible property. This allows me to set the position whenever the window becomes visible. Using above WindowAccessor my ContentView implementation looks as follows:
import SwiftUI
import Combine
let WIDTH: CGFloat = 400
let HEIGHT: CGFloat = 200
struct ContentView: View {
#State var window : NSWindow?
#State private var cancellables = Set<AnyCancellable>()
var body: some View {
VStack {
Text("it finally works!")
.font(.largeTitle)
Text(window?.frameAutosaveName ?? "-")
}
.frame(width: WIDTH, height: HEIGHT, alignment: .center)
.background(WindowAccessor { newWindow in
if let newWindow = newWindow {
monitorVisibility(window: newWindow)
} else {
// window closed: release all references
self.window = nil
self.cancellables.removeAll()
}
})
}
private func monitorVisibility(window: NSWindow) {
window.publisher(for: \.isVisible)
.dropFirst() // we know: the first value is not interesting
.sink(receiveValue: { isVisible in
if isVisible {
self.window = window
placeWindow(window)
}
})
.store(in: &cancellables)
}
private func placeWindow(_ window: NSWindow) {
let main = NSScreen.main!
let visibleFrame = main.visibleFrame
let windowSize = window.frame.size
let windowX = visibleFrame.midX - windowSize.width/2
let windowY = visibleFrame.midY - windowSize.height/2
let desiredOrigin = CGPoint(x: windowX, y: windowY)
window.setFrameOrigin(desiredOrigin)
}
}
I hope this solution helps others who want to get more control to the window in SwiftUI.

UI Test for SwiftUI app does't find element with set accessibilityIdentifier after a timer has passed

The app:
The app has a tag cloud that adds and removes tags as Text views every few seconds to a ZStack using ForEach triggered by an ObservableObject. The ZStack has an accessibilityIdentifier set.
The UI test:
In the UI Test I have set a XCTWaiter first. After a certain period of time has passed I then check if the XCUIElement (ZStack) with the accessibilityIdentifier exists.
After that I query the ZStack XCUIElement for all descendants of type .staticText
I also query the XCUIApplication for its descendants of type .staticText
The following issues:
When the XCTWaiter is set too wait too long. It does not find the ZStack XCUIElement with its identifier anymore.
If the XCTWaiter is set to a low wait time or removed the ZStack XCUIElement will be found. But it will never find its descendants of type .staticText. They do exists though because I can find them as descendant of XCUIApplication itself.
My assumption:
I assume that the ZStack with its identifier can only be found by the tests as long as it does not have descendants. And because it doesn't have any descendants at this moment yet, querying the ZStack XCUIElement for its descendants later also fails because the XCUIElement seems to only represent the ZStack at the time it was captured.
Or maybe I have attached the accessibilityIdentifier for the ZStack at the wrong place or SwiftUI is removing it as soon as there are descendants and I should add identifiers to the descendants only. But that would mean I can only query descendants from XCUIApplication itself and never from another XCUIElement? That would make the .children(matching:) quite useless.
Here is the code for a single view iOS app in SwiftUI with tests enabled.
MyAppUITests.swift
import XCTest
class MyAppUITests: XCTestCase {
func testingTheInitialView() throws {
let app = XCUIApplication()
app.launch()
let exp = expectation(description: "Waiting for tag cloud to be populated.")
_ = XCTWaiter.wait(for: [exp], timeout: 1) // 1. If timeout is set higher
let tagCloud = app.otherElements["TagCloud"]
XCTAssert(tagCloud.exists) // 2. The ZStack with the identifier "TagCloud" does not exist anymore.
let tagsDescendingFromTagCloud = tagCloud.descendants(matching: .staticText)
XCTAssert(tagsDescendingFromTagCloud.firstMatch.waitForExistence(timeout: 2)) // 4. However, it never finds the tags as the descendants of the tagCloud
let tagsDescendingFromApp = app.descendants(matching: .staticText)
XCTAssert(tagsDescendingFromApp.firstMatch.waitForExistence(timeout: 2)) // 3. It does find the created tags here.
}
}
ContentView.swift:
import SwiftUI
struct ContentView: View {
private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
#ObservedObject var tagModel = TagModel()
var body: some View {
ZStack {
ForEach(tagModel.tags, id: \.self) { label in
TagView(label: label)
}
.onReceive(timer) { _ in
self.tagModel.addNextTag()
if tagModel.tags.count > 3 {
self.tagModel.removeOldestTag()
}
}
}.animation(Animation.easeInOut(duration: 4.0))
.accessibilityIdentifier("TagCloud")
}
}
class TagModel: ObservableObject {
#Published var tags = [String]()
func addNextTag() {
tags.append(String( Date().timeIntervalSince1970 ))
}
func removeOldestTag() {
tags.remove(at: 0)
}
}
struct TagView: View {
#State private var show: Bool = true
#State private var position: CGPoint = CGPoint(x: Int.random(in: 50..<250), y: Int.random(in: 100..<200))
let label: String
var body: some View {
let text = Text(label)
.position(position)
.opacity(show ? 0.0 : 1.0)
.onAppear {
show.toggle()
}
return text
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
From what I can see it looks like you are NOT fulfilling the XCTestExpectation you set up with:
let exp = expectation(description: "Waiting...")
In the past, I have used the XCTestExpecation for asynchronous code to signal completion. So like in this example where I set up the XCTestExpectation and provide a description, then made a network call, and on completion of the network call ran exp.fulfill().
let exp = expectation(description: "Recieved success message after uploading onboarding model")
_ = UserController.upsertUserOnboardingModel().subscribe(onNext: { (response) in
expectation.fulfill()
}, onError: { (error) in
print("\(#function): \(error)")
XCTFail()
})
wait(for: [exp], timeout: 10)
Steps should be: Create Expectation -> Fulfil Expectation within X seconds.
I don't see any exp.fulfill() in your provided code so looks like that is a missing step.
Proposed Solutions:
A. So what you could do is fulfill your exp at some point during the number of
seconds specified in the timeout: x.
B. Or you want just wanting a delay you could use sleep(3)
C. Or if you want to wait for existence pick an element and use .waitForExistence(timeout: Int)
Example: XCTAssert(app.searchFields["Search for cars here"].waitForExistence(timeout: 5))

How to make a LongPressGesture that runs repeatedly while the button is still being held down in SwiftUI?

I'd like to run the code in the longPressGesture every 0.5 seconds while the button is being held down. Any ideas on how to implement this?
import SwiftUI
struct ViewName: View {
var body: some View {
VStack {
Button(action: { } ) {
Image(systemName: "chevron.left")
.onTapGesture {
//Run code for tap gesture here
}
.onLongPressGesture (minimumDuration: 0.5) {
//Run this code every 0.5 seconds
}
}
}
}
You can do this by using timer. Make the timer starts when the user long pressed the image, and if the timer reaches 0, you can add two actions: 1. resetting the timer back to 0.5 seconds and 2.code you want to run every 0.5 seconds
struct ContentView: View {
#State var timeRemaining = 0.5
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
#State var userIsPressing = false //detecting whether user is long pressing the screen
var body: some View {
VStack {
Image(systemName: "chevron.left").onReceive(self.timer) { _ in
if self.userIsPressing == true {
if self.timeRemaining > 0 {
self.timeRemaining -= 0.5
}
//resetting the timer every 0.5 secdonds and executing code whenever //timer reaches 0
if self.timeRemaining == 0 {
print("execute this code")
self.timeRemaining = 0.5
}
}
}.gesture(LongPressGesture(minimumDuration: 0.5)
.onChanged() { _ in
//when longpressGesture started
self.userIsPressing = true
}
.onEnded() { _ in
//when longpressGesture ended
self.userIsPressing = false
}
)
}
}
}
Oh boy, I'm not really an expert but I've had a similar problem (detecting pressing and releasing) recently and the solution I've found is less than elegant. I'd love if someone show a more elegant solution but here's my monstrosity:
import SwiftUI
import Combine
struct ContentView: View {
#State private var ticker = Ticker()
#State private var isPressed: Bool = false
#State private var timePassed: TimeInterval?
var body: some View {
Button(action: {
// Action when tapped
NSLog("Tapped!")
}) {
Text(self.isPressed ? "Pressed for: \(String(format: "%0.1f", timePassed ?? 0))" : "Press and hold")
.padding()
.background(Capsule().fill(Color.yellow))
}
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { (value) in
self.isPressed = value
if value == true {
self.timePassed = 0
self.ticker.start(interval: 0.5)
}
}, perform: {})
.onReceive(ticker.objectWillChange) { (_) in
// Stop timer and reset the start date if the button in not pressed
guard self.isPressed else {
self.ticker.stop()
return
}
// Your code here:
self.timePassed = self.ticker.timeIntervalSinceStarted
}
}
}
/// Helper "ticker" that will publish regular "objectWillChange" messages
class Ticker: ObservableObject {
var startedAt: Date = Date()
var timeIntervalSinceStarted: TimeInterval {
return Date().timeIntervalSince(startedAt)
}
private var timer: Timer?
func start(interval: TimeInterval) {
stop()
startedAt = Date()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
self.objectWillChange.send()
}
}
func stop() {
timer?.invalidate()
}
deinit {
timer?.invalidate()
}
}
This requires an explanation:
onTapGesture() is not necessary here because that's what Button does by default, so just putting the code you need to run in the action block should be sufficient;
There is a limited number of gestures available in SwiftUI and, as far as I know, the only way to make new gestures is to combine existing ones;
There is no gesture that would continuously execute some code as long as the button is pressed, but LongPressGesture might be the closest to it. However, this gesture is recognized (and ends) when the allotted time expires but you want to detect the touch as long as it lasts, hence the minimumDuration: .infinity parameter;
LongPressGesture would also end when the touch has moved away a long enough distance, however, that's not how the Button works – you can wander away and return back and, as long as you've lifted the touch on top of the button view, the gesture will be recognized as a button press. We should replicate this behavior in our long press as well, hence maximumDistance: .infinity;
With these parameters, the LongPressGesture will never be recognized, but there is a press parameter that now allows us to be notified when presses start and end;
Some sort of a timer could be used to execute a code block every so often; I've copied this "ticker" ObservableObject from somewhere. It has to be an ObservableObject because, that way, we can subscribe to it's updates within the View;
Now, when the button in pressed, we start the ticker;
When ticker ticks, we capture that with the onReceive() subscriber and that allows us to do something on every tick.
Something like that; again, I'd love someone to show me a better way :)
Good luck with your project!
–Baglan
I simply cleaned up #Baglan 's "monstrosity" a bit this morning.
import Foundation
import SwiftUI
struct LongPressButton: View {
#ObservedObject var timer = PressTimer()
enum PressState {
case inactive
case pressing
case finished
}
#State private var pressState = PressState.inactive
var duration: Double = 2.0
var body: some View {
button
.onLongPressGesture(minimumDuration: duration, maximumDistance: 50, pressing: { (value) in
if value == true {
/// Press has started
self.pressState = .pressing
print("start")
self.timer.start(duration)
} else {
/// Press has cancelled
self.pressState = .inactive
print("stop")
self.timer.stop()
}
}, perform: {
/// Press has completed successfully
self.pressState = .finished
print("done")
})
}
var button: some View {
pressState == .pressing ? Text("Pressing - \(String(format: "%.0f", timer.percent))%")
: Text("Start")
}
}
class PressTimer: ObservableObject {
#Published var percent: CGFloat = 0
private var count: CGFloat = 0
private let frameRateHz: CGFloat = 60
private var durationSeconds: CGFloat = 2
var timer: Timer?
func start(_ duration: Double = 2.0) {
self.durationSeconds = CGFloat(duration)
let timerInterval: CGFloat = 1 / frameRateHz
timer = Timer.scheduledTimer(withTimeInterval: Double(timerInterval), repeats: true, block: { _ in
self.count += timerInterval
self.percent = self.count / self.durationSeconds * 100
})
}
func stop() {
self.count = 0
self.percent = 0
self.timer?.invalidate()
self.timer = nil
}
}

SwiftUI flashing animation to black rather than previous colour

I have a task running in my view model and when it is complete I want the background colour to flash red for a few seconds before staying red. I can get this working fairly well using the playground code below.
But the issue is I want the flashing to be [red, black, red, black, red] not [orange, red, orange, red, orange, red]. I feel like this should be fairly easy (it was in UIKit!) but I can't come up with a good way to do it that doesn't involve me adding something complicated and bug prone like having something in my view model that switches the colour back and forth quickly.
Presumeably something using AnimatableModifier is the way to go, but given my failure to take example code and get it to do anything resembling what I want I clearly don't understand how it works...
import SwiftUI
import PlaygroundSupport
class ViewModel: ObservableObject {
enum TaskState {
case started
case nearlyFinished
case finished
}
private var timer: Timer?
private var timerTwo: Timer?
#Published var myTaskState: TaskState
init() {
myTaskState = .started
self.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false, block: {_ in self.myTaskState = .nearlyFinished})
self.timerTwo = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false, block: {_ in self.myTaskState = .finished})
}
}
struct ContentView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
ZStack {
myColor
Text("Test").foregroundColor(.white)
}
}
private var myColor: some View {
var repeatingAnimation: Animation {
Animation
.linear(duration: 0.5)
.repeatCount(9, autoreverses: true)
}
switch viewModel.myTaskState {
case .started: return Color.black.animation(.linear(duration: 0.0))
case .nearlyFinished: return Color.orange.animation(.linear(duration: 0.0))
case .finished: return Color.red.animation(repeatingAnimation)
}
}
}
PlaygroundPage.current.setLiveView(ContentView(viewModel: ViewModel()))
I figured it out! Thanks to this video for the explaining it in a way that made sense to me.
Perhaps there is a simpler way, but I created an AnimatableModifier that adjusts its opacity between transparent and an input colour according to a sine wave with an input number of peaks and then put it in front of the background colour.
I had to add the .onReceive and .withAnimation because if I you just have it as in the commented out lines below it never goes fully black. Perhaps because some other properties are also being animated?
import SwiftUI
import PlaygroundSupport
class ViewModel: ObservableObject {
enum TaskState {
case started
case nearlyFinished
case finished
}
private var timer: Timer?
private var timerTwo: Timer?
#Published var myTaskState: TaskState
init() {
myTaskState = .started
self.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false, block: {_ in self.myTaskState = .nearlyFinished})
self.timerTwo = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false, block: {_ in self.myTaskState = .finished})
}
}
struct ContentView: View {
#ObservedObject var viewModel: ViewModel
#State private var flashing = false
var body: some View {
ZStack {
myColor
Color.clear
//.modifier(AnimatableFlasher(flashColor: .black, flashes: 10, pct: viewModel.myTaskState == .finished ? 1.0 : 0.0))
//.animation(.linear(duration: 5.0))
.modifier(AnimatableFlasher(flashColor: .black, flashes: 10, pct: self.flashing ? 1.0 : 0.0))
.onReceive(viewModel.$myTaskState, perform: { myTaskState in
withAnimation(.linear(duration: 5.0)) {
self.flashing = myTaskState == .finished
}
})
Text("Test").foregroundColor(.white)
}
}
struct AnimatableFlasher: AnimatableModifier {
let flashColor: UIColor
let flashes: Int
var pct: CGFloat = 0
var animatableData: CGFloat {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
let opacity = oscillateOpacity(flashes: flashes, pct: pct)
return Color(flashColor).opacity(opacity)
}
func oscillateOpacity(flashes: Int, pct: CGFloat) -> Double {
let reducedPct = pct - floor(pct)
return (1.0 - cos(Double(reducedPct)*Double(flashes)*Double.pi*2)) / 2.0
}
}
private var myColor: some View {
switch viewModel.myTaskState {
case .started: return Color(.black)
case .nearlyFinished: return Color(.orange)
case .finished: return Color(.red)
}
}
}
PlaygroundPage.current.setLiveView(ContentView(viewModel: ViewModel()))