I've been struggling with Measurements when used in SwiftUI. I want to be able to convert measurements on the fly, but only the first conversion is working. All subsequent ones fail.
I've managed to narrow it down to a reproducible test case.
First, a quick check to see that Foundation works:
// Let’s check that conversion is possible, and works.
var test = Measurement<UnitLength>(value: 13.37, unit: .meters)
print("Original value: \(test.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
// prints: Original value: 13.37 m
test.convert(to: .centimeters)
print("In centimeters: \(test.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
// prints: In centimeters: 1,337 cm
test.convert(to: .kilometers)
print("In kilometers: \(test.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
// prints: In kilometers: 0.01337 km
test.convert(to: .meters)
print("Back to meters: \(test.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
// prints: Back to meters: 13.37 m
Okay, so it works on the Foundation level. I can convert measurements back and forth, many times.
Now run this ContentView below, and click/tap any button. First one will succeed, the other ones will fail.
struct ContentView: View {
#State var distance = Measurement<UnitLength>(value: 13.37, unit: .meters)
var body: some View {
VStack {
Text("Distance = \(distance.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
Button("Convert to cm") { print("Convert to cm"); distance.convert(to: .centimeters) }
Button("Convert to m") { print("Convert to m"); distance.convert(to: .meters) }
Button("Convert to km") { print("Convert to km"); distance.convert(to: .kilometers) }
}
.onChange(of: distance, perform: { _ in
print("→ new distance = \(distance.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
})
.frame(minWidth: 300)
.padding()
}
}
This is reproducible both on macOS and iOS.
Why on Earth does the first conversion succeed, then all subsequent ones fail? The onChange isn't even triggered.
I'm on Xcode 13.3, macOS 12.3, iOS 15.4
If you take a look at Measurement's header you'll see it is a ReferenceConvertible and the docs for that state "A decoration applied to types that are backed by a Foundation reference type." so it probably doesn't have the value semantics that #State requires for SwiftUI to detect changes. Here is a workaround:
import SwiftUI
struct MTContentViewConfig: Equatable {
var distance = Measurement<UnitLength>(value: 13.37, unit: .meters)
var seed = 0
public mutating func convert(to otherUnit: UnitLength) {
distance.convert(to: otherUnit)
seed += 1
}
}
struct MTContentView: View {
#State var config = MTContentViewConfig()
var body: some View {
VStack {
Text("Distance = \(config.distance.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
Button("Convert to cm") { print("Convert to cm")
config.convert(to: .centimeters) }
Button("Convert to m") { print("Convert to m")
config.convert(to: .meters) }
Button("Convert to km") { print("Convert to km")
config.convert(to: .kilometers) }
}
.onChange(of: config) { newVal in
print("→ new distance = \(newVal.distance.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
}
.frame(minWidth: 300)
.padding()
}
}
By the way instead of doing manual formatting I would recommend supplying Text with a MeasurementFormatter, this way Text will update the UILabel automatically when region settings change.
Measurement has not gotten a lot of love in the last few years from Apple. While this should work in SwiftUI, it is not. The answer is to do the conversion in a view model class, and let it update the published value. Something like this:
class MeasurementConverterViewModel: ObservableObject {
#Published var distance: Measurement<UnitLength>
init(distance: Measurement<UnitLength>) {
self.distance = distance
}
public func convertToCM() {
distance.convert(to: .centimeters)
}
public func convertToM() {
distance.convert(to: .meters)
}
public func convertToKM() {
distance.convert(to: .kilometers)
}
}
This does work, though I would check Open Radar to see if anyone posted a bug report AND file the bug. This should work just as you coded in SwiftUI.
Related
Setup:
My app uses a SwiftUI Map, essentially as
struct MapViewSWUI: View {
#Binding private var show_map_modal: Bool
#State private var region: MKCoordinateRegion
//…
init(show_map_modal: Binding<Bool>) {
self._show_map_modal = show_map_modal
self.region = // Some computed region
//…
var body: some View {
//…
Map(coordinateRegion: $region)
.frame(width: 400, height: 300) // Some frame for testing
}
}
Using this code, I can show the map modally without problems.
Problem:
If I out comment the .frame view modifier, I get the runtime error
Modifying state during view update, this will cause undefined behavior.
with the following stack frame:
Question:
Why is it in my case required to set a frame for the Map? This tutorial does it, but Apple's docs don't.
How to do it right?
PS:
I have read this answer to a similar question and tried to catch the error with a runtime breakpoint, but it does not show anything interesting:
I found an answer to another questions related to the same error, but it doesn't apply here.
EDIT:
Workaround found, but not understood:
My map is presented modally from another view. This view has a state var that controls the presentation:
#State private var show_map_modal = false
The body of the view consists of a HStack with some views, and a fullScreenCover view modifier is applied to the HStack:
var body: some View {
HStack {
// …
}
.fullScreenCover(isPresented: $show_map_modal) {
MapViewSWUI(show_map_modal: $show_map_modal, itemToBeDisplayed: viewItem)
.ignoresSafeArea(edges: [.leading, .trailing])
}
}
If the map is presented in this way, no run time error is raised.
However, if I include (as it was done up to now) .top or .bottom in the edge set, the run time error Modifying state during view update is raised.
I would be glad for any hint to the reason.
My guess is that the error is not related to the frame at all, but to the update of the region once the sheet is presented.
As you can see in my code, I update the region 3 seconds after presenting the seet. Then, the error shows up.
Could the be happening in your code?
struct ContentView: View {
#State private var show_map_modal = false
var body: some View {
Button {
show_map_modal.toggle()
} label: {
Text("Show me the map!")
}
.sheet(isPresented: $show_map_modal) {
MapViewSWUI(show_map_modal: $show_map_modal)
}
}
}
struct MapViewSWUI: View {
#Binding private var show_map_modal: Bool
#State private var region: MKCoordinateRegion
init(show_map_modal: Binding<Bool>) {
self._show_map_modal = show_map_modal
self.region = MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: 51.507222,
longitude: -0.1275),
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
)
}
var body: some View {
VStack(alignment: .trailing) {
Button("Done") {
show_map_modal.toggle()
}
.padding(10)
Map(coordinateRegion: $region)
}
// .frame(width: 400, height: 300) // Some frame for testing
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.region = MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: 31.507222,
longitude: -1.1275),
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
)
}
}
}
}
I'm new to SwiftUI coming from UIKit. I'm trying to achieve the following:
Given an array of mutable states in a view model that represents current progress to be displayed on a Circle using trim(from:to:)
Loop over each item to render a circle with the current progress and animate changes from the old to the new position
A view model will periodically update the array that is a #Published value in an ObservableObject.
I setup a demo view that looks like this
#StateObject var demoViewModel: DemoViewModel = DemoViewModel()
var body: some View {
ForEach(demoViewModel.progress, id: \.self) { progress in
Circle()
.trim(from: 0, to: progress.progress)
.stroke(style: StrokeStyle(lineWidth: 15, lineCap: .round))
.foregroundColor(.red)
.frame(width: 100)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: demoViewModel.progress)
}
}
and a test view model like this
class DemoViewModel: ObservableObject {
#Published var progress: [Demo] = [.init(progress: 0)]
struct Demo: Hashable {
let progress: Double
}
init() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
let current = self.progress[0]
if current.progress < 1 {
self.progress[0] = .init(progress: current.progress + 0.1)
}
}
}
}
For the sake of the demo the array only has one item. What I expected was on each iteration a new value is written to the item in the array which triggers the animation to animate to that value. What actually happens is the progress updates, but it jumps to each new value for no animation.
Try this approach, where the self.demos[0].progress in init()
is updated rather than creating a new Demo(...) at each time tick.
Note, modified a few other things as well, such as the .animation(...value:..) and in particular the ForEach loop with the added Demo unique id.
struct ContentView: View {
#StateObject var demoViewModel: DemoViewModel = DemoViewModel()
var body: some View {
VStack {
ForEach(demoViewModel.demos) { demo in // <-- here
Circle()
.trim(from: 0, to: demo.progress)
.stroke(style: StrokeStyle(lineWidth: 15, lineCap: .round))
.foregroundColor(.red)
.frame(width: 100)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: demo.progress) // <-- here
}
}
}
}
class DemoViewModel: ObservableObject {
#Published var demos:[Demo] = [Demo(progress: 0)]
struct Demo: Identifiable, Hashable { // <-- here
let id = UUID() // <-- here
var progress: Double // <-- here
}
init() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
if self.demos[0].progress < 1 {
self.demos[0].progress = self.demos[0].progress + 0.1 // <-- here
}
}
}
}
I'm new to Swift/SwiftUI, and so please forgive me if this is trivial.
In an app I am attempting to retrieve the user's location, and fetch nearby websites from Wikipedia to display. A simple example is below:
//
// ContentView.swift
// MRE
//
// Created by Philipp Maier on 2/25/22.
//
import SwiftUI
import CoreLocationUI
import MapKit
import Foundation
struct ContentView: View {
enum ViewState {
case waiting, fetching
}
#State private var viewState = ViewState.waiting
#StateObject private var viewModel = LocationViewModel()
// some values to initialize
#State var listEntries = [
Geosearch(pageid: 45348219,
title: "Addison Apartments",
lat: 35.21388888888889,
lon: -80.84472222222222,
dist: 363.7 ),
Geosearch(pageid: 35914731,
title: "Midtown Park (Charlotte, North Carolina)",
lat: 35.2108,
lon: -80.8363,
dist: 1034.5 )
]
var body: some View {
NavigationView{
VStack{
ZStack(alignment: .leading){
Map(coordinateRegion: $viewModel.mapRegion, showsUserLocation: true)
.frame(height: 300)
LocationButton(.currentLocation, action: {
viewModel.requestAllowLocationPermission()
viewState = .fetching
print("Button Press: Latitude: \(viewModel.mapRegion.center.latitude), Longitude: \(viewModel.mapRegion.center.longitude)")
})
}
HStack{
Text("Latitude: \(viewModel.mapRegion.center.latitude), Longitude: \(viewModel.mapRegion.center.longitude)")
}
switch viewState {
case .waiting:
Text("Waiting for your location")
Spacer()
case .fetching:
List{
ForEach(listEntries) {location in
NavigationLink(destination: Text("Target") ) {
HStack(alignment: .top){
Text(location.title)
}
}
.task{
await fetchNearbyLandmarks(lat: viewModel.mapRegion.center.latitude, lon: viewModel.mapRegion.center.longitude)
}
}
}
}
}
}
}
func fetchNearbyLandmarks(lat: Double, lon: Double) async {
let urlString = "https://en.wikipedia.org/w/api.php?action=query&list=geosearch&gscoord=\(lat)%7C\(lon)&gsradius=5000&gslimit=25&format=json"
print(urlString)
guard let url = URL(string: urlString) else {
print("Bad URL: \(urlString)")
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
let items = try JSONDecoder().decode(wikipediaResult.self, from: data)
listEntries = items.query.geosearch
print ("Loadingstate: Loaded")
} catch {
print ("Loadingstate: Failed")
}
}
}
final class LocationViewModel: NSObject, ObservableObject, CLLocationManagerDelegate {
#Published var mapRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 40, longitude: -80.5), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
let locationManager = CLLocationManager()
override init() {
super.init()
locationManager.delegate = self
}
func requestAllowLocationPermission() {
locationManager.requestLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let latestLocation = locations.first else {
print("Location Error #1")
return
}
DispatchQueue.main.async {
self.mapRegion = MKCoordinateRegion(center: latestLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1))
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Location Error #2:")
print(error.localizedDescription)
}
}
struct wikipediaResult: Codable {
let batchcomplete: String
let query: Query
}
struct Query: Codable {
let geosearch: [ Geosearch ]
}
struct Geosearch: Codable, Identifiable {
var id: Int { pageid }
let pageid: Int
let title: String
let lat: Double
let lon: Double
let dist: Double
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The core function of the app works, in that once the user pushes the "Location" button, the list of Wikipedia pages underneath updates. I've also added to Text fields to track the center of the map as the user pans around.
Here's my issue: If I pan the map, the list of locations underneath does not update. However, once I click on a link to display the page, and hit the "back" button, the updated list is built "correctly", i.e. with the new coordinates.
I'm sure that I'm overlooking something simple, but how can I track panning of the user and dynamically adjust the list underneath "in real time"?
Thanks, Philipp
For being new to Swift/SwiftUI this is quite cool!
You want to refetch when the coordinates of your Map change. So you could use .onChange, e.g.on the ZStack of the Map:
.onChange(of: viewModel.mapRegion.center.latitude) { _ in
Task {
await fetchNearbyLandmarks(lat: viewModel.mapRegion.center.latitude, lon: viewModel.mapRegion.center.longitude)
}
}
Generally this works, but it might be fetching too often (on every change of latitude). So you might want to add some kind of delay, or try to figure out when the drag ended.
I have an iOS 13.5 SwiftUI (macOS 10.15.6) app that requires the user to navigate two levels deep in a NavigationView hierarchy to play a game. The game is timed. I'd like to use custom back buttons in both levels, but if I do, the timer in the second level breaks in a strange way. If I give up on custom back buttons in the first level and use the system back button everything works. Here is a minimum app that replicates the problem:
class SimpleTimerManager: ObservableObject {
#Published var elapsedSeconds: Double = 0.0
private(set) var timer = Timer()
func start() {
print("timer started")
timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) {_ in
if (Int(self.elapsedSeconds * 100) % 100 == 0) { print ("\(self.elapsedSeconds)") }
self.elapsedSeconds += 0.01
}
}
func stop() {
timer.invalidate()
elapsedSeconds = 0.0
print("timer stopped")
}
}
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: CountDownIntervalPassThroughView()) {
Text("Start the timer!")
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct CountDownIntervalPassThroughView: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var body: some View {
VStack {
NavigationLink(destination: CountDownIntervalView()) {
Text("One more click...")
}
Button(action: {
self.mode.wrappedValue.dismiss()
print("Going back from CountDownIntervalPassThroughView")
}) {
Text("Go back!")
}
}
.navigationBarBackButtonHidden(true)
}
}
struct CountDownIntervalView: View {
#ObservedObject var timerManager = SimpleTimerManager()
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var interval: Double { 10.0 - self.timerManager.elapsedSeconds }
var body: some View {
VStack {
Text("Time remaining: \(String(format: "%.2f", interval))")
.onReceive(timerManager.$elapsedSeconds) { _ in
print("\(self.interval)")
if self.interval <= 0 {
print("timer auto stop")
self.timerManager.stop()
self.mode.wrappedValue.dismiss()
}
}
Button(action: {
print("timer manual stop")
self.timerManager.stop()
self.mode.wrappedValue.dismiss()
}) {
Text("Quit early!")
}
}
.onAppear(perform: {
self.timerManager.start()
})
.navigationBarBackButtonHidden(true)
}
}
Actually, with this example code, there is some strange behavior even if I use the system back, although my full app doesn't have that problem and I can't see any differences that would explain why. But - for the moment I'd like to focus on why this code breaks with multi-level custom back buttons.
Thanks in advance for any thoughts on this...
I've recently encountered an issue in a container View that has a nested list of View items that use a repeatForever animation, that works fine when firstly drew and jumpy after a sibling item is added dynamically.
The list of View is dynamically generated from an ObservableObject property, and its represented here as Loop. It's generated after a computation that takes place in a background thread (AVAudioPlayerNodeImpl.CompletionHandlerQueue).
The Loop View animation has a duration that equals a dynamic duration property value of its passed parameter player. Each Loop has its own values, that may or not be the same in each sibling.
When the first Loop View is created the animation works flawlessly but becomes jumpy after a new item is included in the list. Which means, that the animation works correctly for the tail item (the last item in the list, or the newest member) and the previous wrongly.
From my perspective, it seems related to how SwiftUI is redrawing and there's a gap in my knowledge, that lead to an implementation that causes the animation states to scatter. The question is what is causing this or how to prevent this from happening in the future?
I've minimised the implementation, to improve clarity and focus on the subject.
Let's take a look into the Container View:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var engine: Engine
fileprivate func Loop(duration: Double, play: Bool) -> some View {
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
.overlay(
Circle()
.trim(
from: 0,
to: play ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0,
lineCap: .round,
lineJoin: .round)
)
.animation(
self.audioEngine.isPlaying ?
Animation
.linear(duration: duration)
.repeatForever(autoreverses: false) :
.none
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
)
}
.frame(width: 100, height: 100)
.padding()
}
var body: some View {
VStack {
ForEach (0 ..< self.audioEngine.players.count, id: \.self) { index in
HStack {
self.Loop(duration: self.engine.players[index].duration, play: self.engine.players[index].isPlaying)
}
}
}
}
}
In the Body you'll find a ForEach that watches a list of Players, a #Published property from Engine.
Have a look onto the Engine class:
Class Engine: ObservableObject {
#Published var players = []
func record() {
...
}
func stop() {
...
self.recorderCompletionHandler()
}
func recorderCompletionHandler() {
...
let player = self.createPlayer(...)
player.play()
DispatchQueue.main.async {
self.players.append(player)
}
}
func createPlayer() {
...
}
}
Finally, a small video demo to showcase the issue that is worth more than words:
For this particular example, the last item has a duration that is double the duration of the previous two, that have the same duration each. Although the issue happens regardless of this exemplified state.
Would like to mention that the start time or trigger time is the same for all, the .play a method called in sync!
Edited
Another test after following good practices provided by #Ralf Ebert, with a slight change given my requirements, toggle the play state, which unfortunately causes the same issue, so thus far this does seem to be related with some principle in SwiftUI that is worth learning.
A modified version for the version kindly provided by #Ralf Ebert:
// SwiftUIPlayground
import SwiftUI
struct PlayerLoopView: View {
#ObservedObject var player: MyPlayer
var body: some View {
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
.overlay(
Circle()
.trim(
from: 0,
to: player.isPlaying ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
)
.animation(
player.isPlaying ?
Animation
.linear(duration: player.duration)
.repeatForever(autoreverses: false) :
.none
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
)
}
.frame(width: 100, height: 100)
.padding()
}
}
struct PlayersProgressView: View {
#ObservedObject var engine = Engine()
var body: some View {
NavigationView {
VStack {
ForEach(self.engine.players) { player in
HStack {
Text("Player")
PlayerLoopView(player: player)
}
}
}
.navigationBarItems(trailing:
VStack {
Button("Add Player") {
self.engine.addPlayer()
}
Button("Play All") {
self.engine.playAll()
}
Button("Stop All") {
self.engine.stopAll()
}
}.padding()
)
}
}
}
class MyPlayer: ObservableObject, Identifiable {
var id = UUID()
#Published var isPlaying: Bool = false
var duration: Double = 1
func play() {
self.isPlaying = true
}
func stop() {
self.isPlaying = false
}
}
class Engine: ObservableObject {
#Published var players = [MyPlayer]()
func addPlayer() {
let player = MyPlayer()
players.append(player)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
player.isPlaying = true
}
}
func stopAll() {
self.players.forEach { $0.stop() }
}
func playAll() {
self.players.forEach { $0.play() }
}
}
struct PlayersProgressView_Previews: PreviewProvider {
static var previews: some View {
PlayersProgressView()
}
}
The following demo was created by following the steps (the demo only shows after the stop all to keep it under 2mb maximum image upload in Stack Overflow):
- Add player
- Add player
- Add player
- Stop All (*the animations played well this far)
- Play All (*same issue as previously documented)
- Add player (*the tail player animation works fine)
Found an article reporting a similar issue:
https://horberg.nu/2019/10/15/a-story-about-unstoppable-animations-in-swiftui/
I'll have to find a different approach instead of using .repeatForever
You need to make sure that no view update (triggered f.e. by a change like adding a new player) causes 'Loop' to be re-evaluated again because this could reset the animation.
In this example, I would:
make the player Identifiable so SwiftUI can keep track of the objects (var id = UUID() suffices), then you can use ForEach(self.engine.players) and SwiftUI can keep track of the Player -> View association.
make the player itself an ObservableObject and create a PlayerLoopView instead of the Loop function in your example:
struct PlayerLoopView: View {
#ObservedObject var player: Player
var body: some View {
ZStack {
Circle()
// ...
}
}
That's imho the most reliable way to prevent state updates to mess with your animation.
See here for a runnable example: https://github.com/ralfebert/SwiftUIPlayground/blob/master/SwiftUIPlayground/Views/PlayersProgressView.swift
This problem seems to be generated with the original implementation, where the .animation method takes a conditional and that's what causes the jumpiness.
If we decide not and instead keep the desired Animation declaration and only toggle the animation duration it works fine!
As follows:
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
Circle()
.trim(
from: 0,
to: player.isPlaying ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
)
.animation(
Animation
.linear(duration: player.isPlaying ? player.duration : 0.0)
.repeatForever(autoreverses: false)
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
}
.frame(width: 100, height: 100)
.padding()
Obs: The third element duration is 4x longer, just for testing
The result as desired: