In SwiftUI on Apple Watch, what is the best way to update a String that describes the interval since a Date with Always On State? - swiftui

I'm building an Apple Watch app that uses SwiftUI.
New data periodically comes into the app from an outside source, and I keep track of when that last happened with a #Published Date (called lastUpdatedDate) inside an ObservableObject (store).
In the app, I want to use a SwiftUI Text struct to indicate to the user how long ago the data was updated.
I'm weighing different options, and I'm wondering what the best practice would be for something like this.
Solution #1 - Text.DateStyle
A very simple method I tried was using Text.DateStyle.relative:
Text(store.lastUpdatedDate, style: .relative)
This worked the way I wanted to some degree, because it kept the text up-to-date with the relative interval since the date had happened, but the text is not customizable. I do not want it to show the number of seconds, just minutes or hours.
Solution #2 - Computed Property
Inside the View where I want to display the Text, I have access to the ObservableObject, and added a computed property that uses a function that converts the Date to a String for the relative date (for example, 5 minutes ago) using an extension on Date with a RelativeDateTimeFormatter
Computed property:
private var lastUpdatedText: String {
store.lastUpdatedDate.timeAgoDisplay()
}
Date extension:
extension Date {
func timeAgoDisplay() -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: self, relativeTo: Date())
}
}
This seems like the best solution I've found so far, because it automatically updates every time the app is opened. The downside, however, is that it only seems to update when the app comes to the foreground (when another app or the Clock Face was previously active). This is probably fine, but it could be better on Series 5, Series 6, and Series 7, which have alway-on displays. On watchOS 8, with the Always On State, this text remains on the screen when the wrist is dropped, and when the wrist is raised again. The text can become outdated, because these events do not cause the lastUpdatedText computed property to be updated again.
Update: This is actually not a very good solution. Through more testing, I found out that it is a fluke that it was updating every time the app opened. It was only re-computing the property on app open because other items in the view were getting refreshed, and on its own it would not have actually re-computed every time the app was opened.
Other Possible Solutions
I've considered adding a second #Published variable in the ObservableObject that's a String containing the relative time interval. The downside of this is I would have to manually initialize and update that variable. If I do that, I could manually update the text based on lifecycle functions in ExtensionDelegate, like applicationWillEnterForeground(), but that only handles when the app first comes to the foreground (which is the same update frequency as the computed property). I haven't found any way to detect when the wrist is dropped or raised again, so it seems the only way I could keep it up to date is to set up one or more published Timers, and update the #Published String every minute, disabling and setting up the timer(s) every time the app goes into the background and returns to the foreground. Does that seem like a good solution? Is there a better solution I'm overlooking?
Solution
Based on the accepted answer, I was able to use a TimelineView to update periodically.
Even though I am only using minute granularity for the text that's displayed, I wanted the text to update to the second when the data was actually refreshed. To accomplish that part, I started by adding a new extension for Date:
extension Date {
func withSameSeconds(asDate date: Date) -> Date? {
let calendar = Calendar.current
var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute, .second], from: self)
let secondsDateComponents = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute, .second], from: date)
components.second = secondsDateComponents.second
return calendar.date(from: components)
}
}
Then, I created my view used the TimelineView to update the text every minute after the data is refreshed:
struct LastUpdatedTextView: View {
#ObservedObject var store: DataStore
var body: some View {
if let nowWithSameSeconds = Date().withSameSeconds(asDate: store.lastUpdatedDate) {
TimelineView(PeriodicTimelineSchedule(from: nowWithSameSeconds,
by: 60))
{ context in
Text(store.lastUpdatedText(forDate: lastUpdatedDate))
}
} else {
EmptyView()
}
}
}

Apple addresses this in Build A Workout App from WWDC21
They use TimelineView and context.cadence == .live to tell their formatter to not show milliseconds when the watch is in Alway on.
You could use that code to determine what to show.
struct TimerView: View {
var date: Date
var showSubseconds: Bool
var fontWeight: Font.Weight = .bold
var body: some View {
if #available(watchOSApplicationExtension 8.0, watchOS 8.0, iOS 15.0, *) {
//The code from here is mostly from https://developer.apple.com/wwdc21/10009
TimelineView(MetricsTimelineSchedule(from: date)) { context in
ElapsedTimeView(elapsedTime: -date.timeIntervalSinceNow, showSubseconds: context.cadence == .live)
}
} else {
Text(date,style: .timer)
.fontWeight(fontWeight)
.clipped()
}
}
}
#available(watchOSApplicationExtension 8.0, watchOS 8.0, iOS 15.0,*)
private struct MetricsTimelineSchedule: TimelineSchedule {
var startDate: Date
init(from startDate: Date) {
self.startDate = startDate
}
func entries(from startDate: Date, mode: TimelineScheduleMode) -> PeriodicTimelineSchedule.Entries {
PeriodicTimelineSchedule(from: self.startDate, by: (mode == .lowFrequency ? 1.0 : 1.0 / 30.0))
.entries(from: startDate, mode: mode)
}
}
struct ElapsedTimeView: View {
var elapsedTime: TimeInterval = 0
var showSubseconds: Bool = false
var fontWeight: Font.Weight = .bold
#State private var timeFormatter = ElapsedTimeFormatter(showSubseconds: false)
var body: some View {
Text(NSNumber(value: elapsedTime), formatter: timeFormatter)
.fontWeight(fontWeight)
.onChange(of: showSubseconds) {
timeFormatter.showSubseconds = $0
}
.onAppear(perform: {
timeFormatter = ElapsedTimeFormatter(showSubseconds: showSubseconds)
})
}
}

Related

SwiftUICharts not changing color on bar chart

I am trying to build a watchOS app that has charting and I can't use Swifts built in charts because I need to support down to version 7 and swift charts are only available for watchOS 9+. So instead I am using a library I found here...
https://github.com/willdale/SwiftUICharts
It has some sample and examples, trying to follow them I was able to get the chart to show up and customize it some, but I can't get the bar chart items to change their color. It seems simple enough, but for whatever reason I can't get it to actually change the color.
I'll provide a simple sample that should help show what's going on.
struct ChartView: View {
var items: [TimeEntries] = [
TimeEntry(dateString: "01/23/2023", entry: 97, timestamp: Date().millisecondsSince1970)]
var body: some View {
let chartData = makeData(items)
BarChart(chartData: chartData)
.touchOverlay(chartData: chartData)
.padding()
}
private func makeData(_ items: [TimeEntries]) -> BarChartData {
var data: [BarChartDataPoint] = [BarChartDataPoint]()
for item in items {
let stat = BarChartDataPoint(
value: Double(item.entry),
xAxisLabel: "Wed",
date: Date(milliseconds: entry.timestamp),
colour: ColourStyle(colour: Color.purple)
)
data.append(stat)
}
let dataSet = BarDataSet(dataPoints: data)
return BarChartData(dataSets: dataSet)
}
}
That should give me an entry on my bar chart with purple filling, I simplified this for sake of ease of posting, my real data has 7 points in it.
However, what actually happens is I have a single red bar on my chart. I am not using red anywhere in the app at all, but it won't take the color that I specify in the colour property of the BarChartDataPoint.
I know it's based on a library, but hopefully someone here will have used this library and will know what I have done wrong. I'll attach a screenshot of the chart so you can see. Thank you.

Why are my SwipeActions overflowing the listview SwiftUI

Why is my SwiftUI Swipe Action behaving like this?
I don't now how to add a GIF in stack overflow so here is a imagur link https://imgur.com/a/9MqjIgX.
If you don't want to click on external links here is a image from the GIF:
My View:
struct MyView: View {
#State var shops = [Shop.empty(), Shop.empty(), Shop.empty(), Shop.empty(), Shop.empty()]
var body: some View {
NavigationView {
List($shops) { $shop in
Text(shop.name)
.swipeActions {
Button {
shop.toggleFavourite()
} label: {
Image(systemName: "star")
}
}
}
}
}
}
the shop struct:
struct Shop: Hashable, Identifiable {
var id: UUID
var favourite: Bool
init(id: UUID){
self.id = id
self.favourite = UserDefaults.standard.bool(forKey: id.uuidString)
}
mutating func toggleFavourite() {
favourite.toggle()
UserDefaults.standard.set(favourite, forKey: id.uuidString)
}
static func empty() -> Shop{
Shop(id: UUID())
}
}
But I can't sadly I can't give you a working example, because I tried to run this code in a fresh app and it worked, without the Bug. On the same device. And I don't understand why, because I also put this view in the root of my old project, just for testing, and the bug stayed there.
I was able to figure out, that if I commented out this line:
UserDefaults.standard.set(favourite, forKey: id.uuidString)
my code would work. But unfortunately I can't just leave out this line of code.
I tried several things, including wrapping this line into DispatchQueue.main.async {} and DispatchQueue.main.sync {}, same with the DispatchQueue.global(). I also added delays. Short delays wouldn't work at all (under .5 seconds) and longer delays would just delay the view bug.
Of course I also tried wrapping this line into a separate function, and so on.
There are two mayor points, why I'am so confused:
Why is the line, that sets this to the Userdefaults even influencing the view? I mean I checked with a print statement, that the initializer, which is the only section in my code that checks this Userdefaultvalue, only gets called when the view gets initialized.
Why does the code work in a different project?
I know since I can't provide a working example of my bug it's hard for you to figure out whats wrong. If you have any ideas, I would be very happy!

Binding<String> action tried to update multiple times per frame in SwiftUI

I have a VM that is implemented as follows:
LoginViewModel
class LoginViewModel: ObservableObject {
var username: String = ""
var password: String = ""
}
In my ContentView, I use the VM as shown below:
#StateObject private var loginVM = LoginViewModel()
var body: some View {
NavigationView {
Form {
TextField("User name", text: $loginVM.username)
TextField("Password", text: $loginVM.password)
Every time I type something in the TextField it shows the following message in the output window:
Binding<String> action tried to update multiple times per frame.
Binding<String> action tried to update multiple times per frame.
Binding<String> action tried to update multiple times per frame.
It is a message and not an error.
If I decorate my username and password properties with #Published then the message goes away but the body is rendered each time I type in the TextField.
Any ideas what is going on and whether I should use #Published or not. I don't think I will gain anything from putting the #Published attribute since this is a one-way binding and I don't want to display anything on the view once the username changes.
If I decorate my username and password properties with #Published then the message goes away
This is the correct solution. You need to use #Published on those properties because that is how SwiftUI gets notified when the properties change.
the body is rendered each time I type in the TextField
That is fine. Your body method is not expensive to compute.
I don't think I will gain anything from putting the #Published attribute since this is a one-way binding
You cannot be sure SwiftUI will work correctly (now or in future releases) if you don't use #Published. SwiftUI expects to be notified when the value of a Binding changes, even when a built-in SwiftUI component like TextField causes the change.
For the simple case - the state is kept in the same view or in a ModelSupport class, consists of strings or other primitive types, and there's only one of each, #Published will work fine.
I got this error with a model class containing an array of structs and using a List, and every time you type inside a TextField inside a list (or every time you select an item in a list), the view gets refreshed, and the error gets triggered.
I am thus using a DelayedTextField:
struct DelayedTextField: View {
var title: String = ""
#Binding var text: String
#State private var tempText: String = ""
var body: some View {
TextField(title, text: $tempText, onEditingChanged: { editing in
if !editing {
$text.wrappedValue = tempText
}
})
.onAppear {
tempText = text
}
}
}
and the binding update error is no more.

SwiftUI View not updating on async change to published properties of Observed Object

I have the following SwiftUI View:
struct ProductView: View {
#ObservedObject var productViewModel: ProductViewModel
var body: some View {
VStack {
ZStack(alignment: .top) {
if(self.productViewModel.product != nil) {
URLImage(url: self.productViewModel.product!.imageurl, itemColor: self.productViewModel.selectedColor)
}
else {
Image("loading")
}
}
}
}
that observes a ProductViewModel
class ProductViewModel: ObservableObject {
#Published var selectedColor: UIColor = .white
#Published var product: Product?
private var cancellable: AnyCancellable!
init(productFuture: Future<Product, Never>) {
self.cancellable = productFuture.sink(receiveCompletion: { comp in
print(comp)
}, receiveValue: { product in
self.product = product
print(self.product) // this prints the expected product. The network call works just fine
})
}
The Product is a Swift struct that contains several string properties:
struct Product {
let id: String
let imageurl: String
let price: String
}
It is fetched from a remote API. The service that does the fetching returns a Combine future and passes it to the view model like so:
let productFuture = retrieveProduct(productID: "1")
let productVM = ProductViewModel(productFuture: productFuture)
let productView = ProductView(productViewModel: productViewModel)
func retrieveProduct(productID: String) -> Future<Product, Never>{
let future = Future<Product, Never> { promise in
// networking logic that fetches the remote product, once it finishes the success callback is invoked
promise(.success(product))
}
return future
}
For the sake of brevity, I've excluded the networking and error handling logic since it is irrelevant for the case at hand. To reproduce this as quickly as possible, just initialize a mock product with some dummy values and pass it to the success callback with a delay like this:
let mockproduct = Product(id: "1", imageurl: "https://exampleurl.com", price: "$10")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: {
promise(.success(mockproduct))
})
Once the product arrives over the network, it is assigned to the published product property.
The fetching works and the correct value is assigned to the published property. Obviously this happens after the view has been created since the network call takes some time. However, the View never updates even though the published object is changed.
When I pass the product directly through the View Model initializer rather than the future, it works as expected and the view displays the correct product.
Any suggestions on why the view does not react to changes in the state of the view model when it is updated asynchronously through the combine future?
EDIT: When I asked this question I had the ProductViewModel + ProductView nested inside another view. So basically the productview was only a part of a larger CategoryView. The CategoryViewmodel initialized both the ProductViewModel and the ProductView in a dedicated method:
func createProductView() -> AnyView {
let productVM = productViewModels[productIndex]
return AnyView(ProductView(productViewModel: productVM))
}
which was then called by the CategoryView on every update. I guess this got the Published variables in the nested ProductViewModel to not update correctly because the view hierarchy from CategoryView downwards got rebuilt on every update. Accordingly, the method createProductView got invoked on every new update, resulting in a completely new initialization of the ProductView + ProductViewModel.
Maybe someone with more experience with SwiftUI can comment on this.
Is it generally a bad idea to have nested observable objects in nested views or is there a way to make this work that is not an antipattern?
If not, how do you usually solve this problem when you have nested views that each have their own states?
I have been iterating on patterns like this to find what I think works best. Not sure what the problem is exactly. My intuition suggests that SwiftUI is having trouble making updates on the != nil part.
Here is the pattern that I have been using which has been working.
Define an enum for states in your networking logic
public enum NetworkingModelViewState {
case loading
case hasData
case noResults
case error
}
Add the enumeration as a variable on your "View Model"
class ProductViewModel: ObservableObject {
#Published public var state: NetworkingModelViewState = .loading
}
Update the state as you progress through your networking
self.cancellable = productFuture.sink(receiveCompletion: { comp in
print(comp)
}, receiveValue: { product in
self.product = product
self.state = NetworkingModelViewState.hasData
print(self.product) // this prints the expected product. The network call works just fine
})
Now make a decision in your SwiftUI based on the Enum value
if(self.productViewModel.state == NetworkingModelViewState.hasData) {
URLImage(url: self.productViewModel.product!.imageurl, itemColor: self.productViewModel.selectedColor)
}
else {
Image("loading")
}
Musings ~ It's hard to debug declarative frameworks. They are powerful and we should keep learning them but be aware of getting stuck. Moving too SwiftUI has forced me to really think about MVVM. My takeaway is that you really need to separate out every possible variable that controls your UI. You should not rely on checks outside of reading a variable. The Combine future pattern has a memory leak that Apple will fix next release. Also, you will be able to switch inside SwiftUI next release.

Finding the accessibility(identifier:) of DatePicker() embedded in a Form in SwiftUI using an XCUIElementQuery

I have a SwiftUI view that has a Form that contains a DatePicker:
struct GettingUpTimeSettingView: View {
#State private var viewModel = GettingUpTimeSettingViewModel()
var body: some View {
VStack {
Form {
Spacer()
Text(viewModel.questionString)
.accessibility(identifier: "Question")
Spacer()
DatePicker("Alarm Time",
selection: $viewModel.gettingUpTime,
displayedComponents: .hourAndMinute)
.accessibility(identifier: "Time")
.animation(.easeInOut)
Spacer()
Text(viewModel.explanationString)
.accessibility(identifier: "Explanation")
}
}
}
}
And an XCTestCase class for UI Testing:
class SleepyGPIntroUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
greeting = app.staticTexts["Greeting"]
}
override func tearDown() {
app = nil
}
func test_InitialScreen_ChangesTo_GettingUpScreen_Automatically() {
//Given
let questionText = "What time do you want to get up?"
let explanationText = "This should be the same time every day, including at weekends and on days off. Our best chance of great sleep comes when we have a regular routine."
let timeText = "7:00am"
let question = app.staticTexts["Question"]
let explanation = app.staticTexts["Explanation"]
let time = app.staticTexts["Time"]
//When
_ = time.waitForExistence(timeout: 2)
//Then
XCTAssertFalse(greeting.exists)
XCTAssertEqual(question.label, questionText, "Should show question at top of view")
XCTAssertEqual(explanation.label, explanationText, "Should show explanation in view")
XCTAssertEqual(time.label, timeText, "Should show the correct default time")
}
When I run the test, it fails and gives me this message:
Failed to get matching snapshot: No matches found for Elements matching predicate '"Time" IN identifiers' from input {(
StaticText, identifier: 'Question', label: 'What time do you want to get up?',
StaticText, identifier: 'Explanation', label: 'This should be the same time every day, including at weekends and on days off. Our best chance of great sleep comes when we have a regular routine.'
)}
I'm not sure whether this is down to the DatePicker() being contained in a Form, or whether it's because I'm not using the correct XCUIElementQuery to find it?
When I move the DatePicker outside the Form, I can find it, but only by using its label, and not its accessibility identifier.
The accessibility identifiers are working fine for the Text objects.
I can also find it using
app.datePickers.firstMatch
when it's outside the form, but not when it's contained within it.
I've found this answer that describes some odd behaviour when SwiftUI objects are contained in forms, but still haven't managed to solve my problem.
Many thanks.
tl:dr Can't get XCUIElementQuery to return DatePicker element when UITesting if the DatePicker is contained in a SwiftUI Form.
I've found the answer, for anyone that's interested.
First of all, a piece of basic advice, if you're struggling to find out how to access an element when UI testing in XCode, just use the record function and access the element manually.
That's what I did, and it showed me that before a DatePicker() is tapped on in SwiftUI, it actually shows as a button, so to access it in the example above I used this code:
let alarmTimeButton = app.tables.buttons["Time"]