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

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.

Related

Swift UI - How to access a #State variable externally?

I have ContentView.swift
struct ContentView: View {
#State var seconds: String = "60"
init(_ appDelegate: AppDelegate) {
launchTimer()
}
func launchTimer() {
let timer = DispatchTimeInterval.seconds(5)
let currentDispatchWorkItem = DispatchWorkItem {
print("in timer", "seconds", seconds) // Always `"60"`
print("in timer", "$seconds.wrappedValue", $seconds.wrappedValue) // Always `"60"`
launchTimer()
}
DispatchQueue.main.asyncAfter(deadline: .now() + timer, execute: currentDispatchWorkItem)
}
var body: some View {
TextField("60", text: $seconds)
}
func getSeconds() -> String {
return $seconds.wrappedValue;
}
}
And AppDelegate.swift
class AppDelegate: NSObject, NSApplicationDelegate {
var contentView = ContentView()
func applicationDidFinishLaunching(_ aNotification: Notification) {
launchTimer()
}
func launchTimer() {
print(contentView.getSeconds()) // Always `"60"`
let timer = DispatchTimeInterval.seconds(Int(contentView.getSeconds()) ?? 0)
DispatchQueue.main.asyncAfter(deadline: .now() + timer) {
self.launchTimer()
}
}
#objc func showPreferenceWindow(_ sender: Any?) {
if(window != nil) {
window.close()
}
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = NSHostingView(rootView: contentView)
window.center()
window.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
}
}
contentView.getSeconds() always returns 60, even if I modify the value of the TexField in my content view (by manually typing in the text field).
How does one get the wrapped value/real value of a state variable from the app delegate?
#State/#StateObject is tricky business, what happens is that SwiftUI connects the state values to a certain view instance from the UI hierarchy.
In your case, the #State var seconds: String = "60" is connected to the view below (simplified scheme):
NSWindow
-> NSHostingView
-> ContentView <----- this is where the #State usage is valid
And as others said, ContentView being a struct, it's a value type, meaning that its contents are copied in all usage sites, so instead of having a unique instance, like a class has, you end up with multiple instances.
And only one of those instances, the copy SwiftUI makes when it adds the view to the UI tree, is the one that is connected to the text field, and gets updated.
To make things even funnier, the State property wrapper is also a struct, meaning that it "suffers" from the same symptoms as the view.
That's one of the perks of using SwiftUI, in contrast with UIKit, you don't care at all about the view instances.
Now, if you want to use the value of seconds from AppDelegate, or any other place, you will have to circulate the data storage instead of the view.
For starters, change the view to receive a #Binding instead
struct ContentView: View {
#Binding var seconds: String = "60"
// the rest of the view code remains the same
Then create an ObservableObject that stores the data, and inject its $seconds binding when creating the view:
class AppData: ObservableObject {
#Published var seconds: String = "60"
}
class AppDelegate {
let appData = AppData()
// ... other code left out for clarity
#objc func showPreferenceWindow(_ sender: Any?) {
// ...
let contentView = ContentView(seconds: appData.$seconds
window.contentView = NSHostingView(rootView: contentView)
// ...
}
}
SwiftUI is more about the data than the view itself, so if you want to access the same data in multiple places, make sure you circulate the storage instead of the view the data is attached to. And also make sure that the source of truth of that data is a class, as object references are guaranteed to point to the same memory location (assuming of course the reference doesn't change).
Your ContentView (as well as any other SwiftUI view) is a struct, ie. value type, so in every place where you use it - you use a new copy of initially created value, ie.:
class AppDelegate: NSObject, NSApplicationDelegate {
var contentView = ContentView() // << 1st copy
...
func launchTimer() {
print(contentView.getSeconds()) // << 2nd copy
let timer = DispatchTimeInterval.seconds(Int(contentView.getSeconds()) ?? 0) // << 3d copy
...
}
...
#objc func showPreferenceWindow(_ sender: Any?) {
...
window.contentView = NSHostingView(rootView: contentView) // << 4th copy
}
}
You should not use any #State externally, because it is valid only inside view's body.
If you need something to share/access in different places then use class confirming to ObservableObject as view model type, and as instances of such class is a reference type then passing it here and there you can use/access same object from different places.
Update:
why the value stays to "60" even inside the ContentView
You call launchTimer in init, but in that place State is not constructed yet, so binding to it is not valid. As was already written above state is valid in body, so you have to set your timer also in body when binding will be ready, say in .onAppear, like below
var body: some View {
TextField("60", text: $seconds)
.onAppear {
launchTimer()
}
}
Prepared & tested with Xcode 13 / iOS 15
add a variable to appDelegate
var seconds : Int?
then do this in your contentView :
.onChange(of: seconds) { seconds in
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {return}
appDelegate.seconds = seconds
}
Ended up instantiating the ContentView with the AppDelegate as a param. I added a seconds attribute to the AppDelegate to be modified by the ContentView.
AppDelegate.swift
import SwiftUI
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var contentView: ContentView?
var seconds = 60
func applicationDidFinishLaunching(_ aNotification: Notification) {
self.contentView = ContentView(self)
launchTimer()
}
func launchTimer() {
let timer = DispatchTimeInterval.seconds(seconds)
print(timer)
DispatchQueue.main.asyncAfter(deadline: .now() + timer) {
self.launchTimer()
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
#State var seconds: String = "60"
var appDelegate: AppDelegate
init(_ appDelegate: AppDelegate) {
self.appDelegate = appDelegate
}
var body: some View {
TextField("60", text: $seconds, onCommit: {
self.appDelegate.seconds = Int($seconds.wrappedValue) ?? 0
})
}
}
This code hurts my brain and surely there is a better way of doing this 😅. But I'm only so far in my Swift journey, so that will do for now lol. Please post if you have a better solution.

SwiftUI - How to disable sidebar from collapsing?

Gif to understand easier
Is there any way to disable collapsibility of SidebarListStyle NavigationViews?
EDIT: This method still works as of late 2022, and has never stopped working on any version of macOS (up to latest Ventura 13.1). Not sure why there are answers here suggesting otherwise. If the Introspection library changes their API you may need to update your calls accordingly, but the gist of the solution is the same.
Using this SwiftUI Introspection library:
https://github.com/siteline/SwiftUI-Introspect
We can introspect the underlying NSSplitView by extending their functionality:
public func introspectSplitView(customize: #escaping (NSSplitView) -> ()) -> some View {
return introspect(selector: TargetViewSelector.ancestorOrSibling, customize: customize)
}
And then create a generic extension on View:
public extension View {
func preventSidebarCollapse() -> some View {
return introspectSplitView { splitView in
(splitView.delegate as? NSSplitViewController)?.splitViewItems.first?.canCollapse = false
}
}
}
Which can be used on our sidebar:
var body: some View {
(...)
MySidebar()
.preventSidebarCollapse()
}
The introspection library mentioned by Oskar is not working for MacOS.
Inspired by that, I figured out a solution for MacOS.
The rationality behind the solution is to use a subtle way to find out the parent view of a NavigationView which is a NSSplitViewController in the current window.
Below codes was tested on XCode 13.2 and macOS 12.1.
var body: some View {
Text("Replace with your sidebar view")
.onAppear {
guard let nsSplitView = findNSSplitVIew(view: NSApp.windows.first?.contentView), let controller = nsSplitView.delegate as? NSSplitViewController else {
return
}
controller.splitViewItems.first?.canCollapse = false
// set the width of your side bar here.
controller.splitViewItems.first?.minimumThickness = 150
controller.splitViewItems.first?.maximumThickness = 150
}
}
private func findNSSplitVIew(view: NSView?) -> NSSplitView? {
var queue = [NSView]()
if let root = view {
queue.append(root)
}
while !queue.isEmpty {
let current = queue.removeFirst()
if current is NSSplitView {
return current as? NSSplitView
}
for subview in current.subviews {
queue.append(subview)
}
}
return nil
}
While the method that Oskar used with the Introspect library no longer works, I did find another way of preventing the sidebar from collapsing using Introspect. First, you need to make an extension on View:
extension View {
public func introspectSplitView(customize: #escaping (NSSplitView) -> ()) -> some View {
return inject(AppKitIntrospectionView(
selector: { introspectionView in
guard let viewHost = Introspect.findViewHost(from: introspectionView) else {
return nil
}
return Introspect.findAncestorOrAncestorChild(ofType: NSSplitView.self, from: viewHost)
},
customize: customize
))
}
}
Then do the following:
NavigationView {
SidebarView()
.introspectSplitView { controller in
(controller.delegate as? NSSplitViewController)?.splitViewItems.first?.canCollapse = false
}
Text("Main View")
}
This being said, we don't know how long this will actually work for. Apple could change how NavigationView works and this method may stop working in the future.

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))

Observe singleton timer value changes with Publisher in Combine

One of the requirements of my application is the ability to start multiple timers, for reporting purposes.
I've tried to store the timers and seconds passed in an #EnvironmentObject with #Published variables, but every time the object refreshes, any view that observes the #EnvironmentObject refreshes too.
Example
class TimerManager: ObservableObject {
#Published var secondsPassed: [String: Int]
var timers: [String:AnyCancellable]
func startTimer(itemId: String) {
self.secondsPassed[itemId] = 0
self.timers[itemId] = Timer
.publish(every: 1, on: .main, in: .default)
.autoconnect()
.sink(receiveValue: { _ in
self.secondsPassed[itemId]! += 1
})
}
func isTimerValid(itemId: String) -> Bool {
return self.timers[itemId].isTimerValid
}
// other code...
}
So for example, if in any other view I need to know if a particular timer is active by calling a function isTimerValid, I need to include this #EnvironmentObject in that view, and it won't stop refreshing it because the timer changes secondsPassed which is Published, causing lags and useless redrawings.
So one thing I did was to cache the itemId of the active timers somewhere else, in a static struct that I update every time I start or stop a timer.
It seemed a bit hacky, so lately I've been thinking to move all this into a Singleton, like this for example
class SingletonTimerManager {
static let singletonTimerManager = SingletonTimerManager()
var secondsPassed: [String: Int]
var timers: [String:AnyCancellable]
func startTimer(itemId: String) {
self.secondsPassed[itemId] = 0
self.timers[itemId] = Timer
.publish(every: 1, on: .main, in: .default)
.autoconnect()
.sink(receiveValue: { _ in
self.secondsPassed[itemId]! += 1
})
}
// other code...
}
and only let some Views observe the changes to secondsPassed. On the plus side, I can maybe move the timer on the background thread.
I've been struggling how to do this properly.
These are my Views (albeit a very simple extract)
struct ContentView: View {
// set outside the ContentView
var selectedItemId: String
// timerValue: set by a publisher?
var body: some View {
VStack {
ItemView(seconds: Binding.constant(timerValue))
}
}
}
struct ItemView: View {
#Binding var seconds: Int
var body: some View {
Text("\(self.seconds)")
}
}
I need to somehow observe the SingletonChronoManager.secondsPassed[selectedItemId] so the ItemView updates in real time.
By putting the timer publisher results into Environment, you are propagating change notifications to all views within the tree that define that environment object, which I'm sure will cause un-needed redraws and performance issues (and as you've seen).
A better mechanism is strongly limiting the views (or subviews) that need to display the constantly updating time, and pass in a reference to a timer publisher directly to them rather than layering it into the environment. Putting the timer itself into a singleton is one option but not critical to this, and won't effect the cascading redraws you're seeing.
How to use a timer with SwiftUI has a "shoving a timer into the view itself", which may work for what you're trying to do, but slightly better is the video here: https://www.hackingwithswift.com/books/ios-swiftui/triggering-events-repeatedly-using-a-timer
In Paul's example, he's stuffing the timer into the view itself - wouldn't be my choice, but for a simple real-time clock view it's not bad. You could just as easily pass in the timer publisher from an external object - like your singleton for example.
I've ended up using the following solution, combining #heckj suggestion and this one from #Mykel.
What I did was separating the AnyCancellable from the TimerPublishers by saving them in specific dictionaries of SingletonTimerManager.
Then, every time an ItemView is declared, I instantiate an autoconnected #State TimerPublisher. Every Timer instance now runs in the .common RunLoop, with a 0.5 tolerance to better help the perfomance as suggested by Paul here: Triggering events repeatedly using a timer
During the .onAppear() call of the ItemView, if a publisher with the same itemId already exists in SingletonTimerManager, I just assign that publisher to the one of my view.
Then I handle it like in #Mykel solution, with start and stopping both ItemView's publisher and SingletonTimerManager publisher.
The secondsPassed are shown in a text stored inside #State var seconds, which gets updated with a onReceive() attached to the ItemView's publisher.
I know that I'm probably creating too many publishers with this solution and I can't pinpoint exactly what happens when copying a publisher variable into another, but overall perfomance is much better now.
Sample Code:
SingletonTimerManager
class SingletonTimerManager {
static let singletonTimerManager = SingletonTimerManager()
var secondsPassed: [String: Int]
var cancellables: [String:AnyCancellable]
var publishers: [String: TimerPublisher]
func startTimer(itemId: String) {
self.secondsPassed[itemId] = 0
self.publisher[itemId] = Timer
.publish(every: 1, tolerance: 0.5, on: .main, in: .common)
self.cancellables[itemId] = self.publisher[itemId]!.autoconnect().sink(receiveValue: {_ in self.secondsPassed[itemId] += 1})
}
func isTimerValid(_ itemId: String) -> Bool {
if(self.cancellables[itemId] != nil && self.publishers[itemId] != nil) {
return true
}
return false
}
}
ContentView
struct ContentView: View {
var itemIds: [String]
var body: some View {
VStack {
ForEach(self.itemIds, id: \.self) { itemId in
ItemView(itemId: itemId)
}
}
}
}
struct ItemView: View {
var itemId: String
#State var seconds: Int
#State var timerPublisher = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Button("StartTimer") {
// Call startTimer in SingletonTimerManager....
self.timerPublisher = SingletonTimerManager.publishers[itemId]!
self.timerPublisher.connect()
}
Button("StopTimer") {
self.timerPublisher.connect().cancel()
// Call stopTimer in SingletonTimerManager....
}
Text("\(self.seconds)")
.onAppear {
// function that checks if the timer with this itemId is running
if(SingletonTimerManager.isTimerValid(itemId)) {
self.timerPublisher = SingletonTimerManager.publishers[itemId]!
self.timerPublisher.connect()
}
}.onReceive($timerPublisher) { _ in
self.seconds = SingletonTimerManager.secondsPassed[itemId] ?? 0
}
}
}
}

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

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
}
}
}
}
}