I ca use negative values in a SwifUI PrgressView within the parent view, which changes the
progress bar into an indeterminate view, but I cannot make the value negative from outside the
parent view. Why? I have no problem passing the value, but the ProgressView hiccups when I try
to used the passed value.
I have been playing around with the SwiftUI ProgressView in hopes of finding a
nice trick to turn an indeterminate view on & off programatically. I found
that if I create a determinate view with a negative (out of range) number, it
becomes an indeterminate view, all be it with a warning that the value is out
of range - But it works! The following code snipit shows this:
struct ContentView: View {
var body: some View {
ProgressView("Value is 0.5: ", value: 0.5)
.padding()
ProgressView("Value is -0.5: ", value: -0.5)
.padding()
}
}
If I try to set the value outside the ProgressView(), as follows, all is still
good, though the warning remains:
struct ContentView: View {
#State var negativeValue = -0.5
var body: some View {
ProgressView("Value is 0.5: ", value: 0.5)
.padding()
ProgressView("Value is -0.5: ", value: negativeValue)
.padding()
}
}
However, if I try to change the value from somewhere outside the parent struct, as shown below, it compiles but won't change values & gives the error message "[SwiftUI] ProgressView
initialized with an out-of-bounds progress value. The value will be clamped to the range of
0...total." Yet it was willing to use the negative value in the 1st 2 examples.
struct ContentView: View {
#State var negativeValue = 0.75
var body: some View {
ProgressView("Value is 0.5: ", value: 0.5)
.padding()
ProgressView("Value is -0.5: ", value: negativeValue)
.padding()
Button("Change Value") {
ExtStruct.doChange()
negativeValue = ExtStruct.newValue
}
}
}
struct ExtStruct {
static var newValue = 0.0
static func doChange() {newValue = -0.5}
}
I have used this trick before with other views, such as Text(), but it does not
seem to work with ProgressView. These examples were set up in Xcode Version 14.1
(14B47b) but showed the same behavior before upgrading. There are other ways of achieving
what I need but this seems so simple. What am I missing?
Related
Revised Example
Based on the thoughtful response from #pawello2222 I received in the comments below I have revised an example to demonstrate the issue I am wrestling with.
In order to demonstrate the issue I am having I am using 2 views a parent and a child. In my code code the parent view executes multiple steps but sometimes the animation subview is not visible in the first step. However when it does become visible the animation has already taken on the appearance of the end state. You can see this behavior in the following example.
Parent View
struct ContentView: View {
#State var firstTime = true
#State var breath = false
var body: some View {
VStack {
// This subview is not displayed until after the first time
if !firstTime {
SecondView(breath: $breath)
}
Spacer()
// A button click simulates the steps in my App by toggling the #Binding var
Button("Breath") {
withAnimation {
self.breath.toggle()
self.firstTime = false
}
}
// This vies shows what happens when the subview is being displayed with an intial state of false for the #Binding var
Spacer()
SecondView(breath: $breath)
}
}
}
Here is the subview containing the animation and using a #Binding var to control the animation appearance.
struct SecondView: View {
#Binding var breath: Bool
var body: some View {
Image(systemName: "flame")
.resizable()
.rotationEffect(.degrees(breath ? 360 : 0), anchor: .center)
.scaleEffect(breath ? 1 : 0.2)
.opacity(breath ? 1 : 0.75)
.animation(.easeInOut(duration: 2))
.foregroundColor(Color.red)
}
}
When you execute this the first time thru the top subview is not being displayed and when you click the button the lower subview executes the expected animation and then toggles the firstTime var so that the top subview becomes visible. Notice that the animation is fully expanded and if I was to then have another step (click) with the same value of true for the #Binding property the view would not change at all. This is the issue I am wrestling with. I would like to keep the subview from being at the end state if the first step is one that has toggled the Bool value even if the subview was not being displayed. In other words I would like to only initialize the subview when it is actually being displayed with a value of true so that the animation will always start out small.
This is why I was hoping to have the subview initialized the Binding var to false until it actually gets invoked for the first time (or to reset its state to the shrunk version of the animation) whichever is more feasible.
It looks like you may want to initialise _breath with the provided parameter:
struct ContentView: View {
#Binding var breath: Bool
init(breath: Binding<Bool>) {
_breath = breath
}
}
However, if you want to use a constant value (in your example false) you can do:
struct ContentView: View {
#Binding var breath: Bool
init(breath: Binding<Bool>) {
_breath = .constant(false)
}
}
But then, why do you need the breath: Binding<Bool> parameter?
EDIT
Here is an example how to control an animation of a child view using a #Binding variable:
struct ContentView: View {
#State var breath = false
var body: some View {
VStack {
Button("Breath") {
withAnimation {
self.breath.toggle()
}
}
SecondView(breath: $breath)
}
}
}
struct SecondView: View {
#Binding var breath: Bool
var body: some View {
Image(systemName: "flame")
.imageScale(.large)
.rotationEffect(.degrees(breath ? 360 : 0), anchor: .center)
.scaleEffect(breath ? 1 : 0.2)
.opacity(breath ? 1 : 0.75)
.animation(.easeInOut(duration: 2))
}
}
I have a bug when using NavigationLinks and an ObservableObject. I don't quite understand why because I don't understand what is happening to the views and data as I am navigating. This is some pseudo-code to illustrate the problem:
class Settings: ObservableObject {
#Published var data: [Int] = [0, 1, 2, 3, 4, 5]
}
struct ContentView: View {
#State var new_view: Bool = false
#ObservedObject var content_view_settings = Settings()
var body: some View {
NavigationView {
VStack {
Button(action: {
DeleteLastItem()
}) {
Text("Delete last item")
}
Button(action: {
self.new_view = true
}) {
Text("New View")
}
NavigationLink(destination: NewView(new_view_settings: content_view_settings), isActive: $new_view) {
EmptyView()
}
}
}
}
}
struct NewView: View {
#ObservedObject var new_view_settings: Settings
#State var index = -1
var body: some View {
VStack {
Button(action: {
self.index = self.new_view_settings.count - 1
}) {
Text("change index")
}
if self.index > -1 {
Text("\(self.new_view_settings.data[index])")
}
}
}
}
The description of the problem is this:
I have a view with an ObservedObject that I pass to a subsequent view upon navigating. This sub-view accesses the last element of the array, but it only does that once the index variable is validated through a button click. The text is then rendered only after the index is validated.
Now, suppose I validate the index so it would equal 5 in this example. Then I navigate back to the original view. If I delete the last element, the index 5 is no longer valid. As soon as I delete that last element I get an invalid index error and the simulator crashes.
But let's say I navigate backward and do not delete the last element. Then when I navigate forward, the index variable is reset.
Since I get the crash, this means the view is still alive and being updated or something but when I navigate to it once again the view is reloaded. Does this mean the view is alive until it gets initialized again? This is contrived code but it is essentially the issue I am having. I thought the original code would be a bit harder to understand.
Does this mean the view is alive until it gets initialized again?
Yes, the view may be alive even after you navigate back to the parent view.
To better understand what's happening run the same code on the iPad simulator (preferably in the horizontal mode). You'll notice that the NavigationView is split in two parts: master and detail - this way you can see both parent and child view at once.
Now, if you perform the same experiment from your question, you'll see the child view remains present even if you navigate back. The same happens on iOS.
One way to prevent this can be to check if indices are present in the array:
struct NewView: View {
#ObservedObject var new_view_settings: Settings
#State var index = -1
var body: some View {
VStack {
Button(action: {
//self.index = self.new_view_settings.count - 1
}) {
Text("change index")
}
// check if `index` is in array
if self.index > -1 && self.index < self.new_view_settings.data.count {
Text("\(self.new_view_settings.data[index])")
}
}
}
}
Note: in general I don't recommend dealing with indices in SwiftUI views - there usually is a better way to pass data. Dealing with indices is risky.
I am trying to pass data up a view chain using Preferences but .onPreferenceChange is never called. Investigating a bit reveals that while the Debugger shows execution passes through my
.Preference modifier, the reduce method is never called to push the new value into the key. I can not figure out why, nor can I find a way to examine the View's Preference key:value data.
here is the code:
struct LittleTapPrefKey: PreferenceKey {
typealias Value = String
static var defaultValue: Value = "A0"
static func reduce(value: inout Value, nextValue: () -> String) {
print("Inside Little Reduce \(value)and NextVal = \(nextValue())")
value = nextValue()
}
}
here is cell View where on tap of a GlyphCell instance in a QGrid, the Pref modifier is set:
struct GlyphCell: View {
var glyph:Glyph
#State private var selected = false // << track own selection
var body: some View {
ZStack {
Text("\(glyph.Hieroglyph)")
.lineLimit(1)
.padding(.all, 12.0)
.font(.system(size: 40.0))
.background(Color.yellow)
Text("\(glyph.Gardiner)")
.offset(x: 12, y: 28)
.foregroundColor(.blue)
}.onTapGesture {
print("Tap on \(self.glyph.Gardiner)")
self.selected.toggle()
}
.cornerRadius(6.0)
.frame(width: 90.0, height: 100.0, alignment: .center)
.previewLayout(.sizeThatFits)
.background(Group {
if self.selected {
Color.clear
.preference(key: LittleTapPrefKey.self, value: self.glyph.Gardiner)
} // end selected test
} // end group
) // end .background
} // end cell body view
}
with a Breakpoint set on the .pref(key:...), the breakpoint triggers every time a cell is tapped, and the value of self.glyph.Gardiner is correct at that point.
Breakpoints or the test Print statement in the Preference Key Struct "reduce" function are never triggered. Since the .onPrefChange is never triggered, I have to assume the Value never gets changed, but I have no way to directly view the Pref value.
What is going on?
The reduce is called when you set one preference key in one view hierarchy several times, so SwiftUI gives you possibility to combine them somehow, eg:
.previewLayout(.sizeThatFits)
.preference(key: LittleTapPrefKey.self, value: value1) // 1st time
.background(Group {
if self.selected {
Color.clear
.preference(key: LittleTapPrefKey.self, value: value2) // 2nd time
}
}
in your case you have only one value setter, so it is just set and .onPreferenceChange(LittleTapPrefKey.self) callback modifier works as expected.
Tested with Xcode 11.4 / iOS 13.4
I've been seeing some strange behavior for preference keys with ScrollView. If I put the onPreferenceChange inside the ScrollView it won't be called, but if I put it outside it does!
I've setup a width preference key as follows:
struct WidthPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
The following simple view does not print:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
.onPreferenceChange(WidthPreferenceKey.self) {
print($0) // Not being called, we're in a scroll view.
}
}
}
}
But this works:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
I know that I can use the latter approach to fix this, but sometimes I'm inside a child view that does not have access to its parent scroll view but I still want to record a preference key.
Any ideas on how to get onPreferenceChange to get called inside a ScrollView?
Note: I get Bound preference WidthPreferenceKey tried to update multiple times per frame. when I put the function inside the scroll view, which might explain what is going on but I can't figure it out.
Thanks!
I had been trying to figure out this issue for a long time and have found how to deal with it, although the way I used was just one of the workarounds.
Use onAppear to ScrollView with a flag to make its children show up.
...
#State var isShowingContent = false
...
ScrollView {
if isShowingContent {
ContentView()
}
}
.onAppear {
self.isShowingContent = true
}
Or,
Use List instead of it.
It has the scroll feature, and you can customize it with its own functionality and UITableView appearance in terms of UI. the most important is that it works as we expected.
[If you have time to read more]
Let me say my thought about that issue.
I have confirmed that onPreferenceChange isn't called at the bootstrap time of a view put inside a ScrollView. I'm not sure if it is the right behavior or not. But, I assume that it's wrong because ScrollView has to be capable of containing any views even if some of those use PreferenceKey to pass any data among views inside it. If it's the right behavior, it would be quite easy for us to get in trouble when creating our custom views.
Let's get into more detail.
I suppose that ScrollView would work slightly different from the other container views such as List, (H/V)Stack when it comes to set up its child view at the bootstrap time. In other words, ScrollView would try to draw(or lay out) children in its own way. Unfortunately, that way would affect the children's layout mechanism working incorrectly as what we've been seeing. We could guess what happened with the following message on debug view.
TestHPreferenceKey tried to update multiple times per frame.
It might be a piece of evidence to tell us that the update of children has occurred while ScrollView is doing something for its setup. At that moment, it could be guessed that the update to PreferenceKey has been ignored.
That's why I tried to put the placing child views off to onAppear.
I hope that will be useful for someone who's struggling with various issues on SwiftUI.
I think onPreferenceChange in your example is not called because it’s function is profoundly different from preference(key…)
preference(key:..) sets a preference value for the view it is used on.
whereas onPreferenceChange is a function called on a parent view – a view on a higher position in the view tree hierarchy. Its function is to go through all its children and sub-children and collect their preference(key:) values. When it found one it will use the reduce function from the PreferenceKey on this new value and all the already collected values. Once it has all the values collected and reduced them it will execute the onPreference closure on the result.
In your first example this closure is never called because the Text(“Hello”) view has no children which set the preference key value (in fact the view has no children at all). In your second example the Scroll view has a child which sets its preference value (the Text view).
All this does not explain the multiple times per frame error – which is most likely unrelated.
Recent update (24.4.2020):
In a similar case I could induce the call of onPreferenceChange by changing the Equatable condition for the PreferenceData. PreferenceData needs to be Equatable (probably to detect a change in them). However, the Anchor type by itself is not equatable any longer. To extract the values enclosed in an Anchor type a GeometryProxy is required. You get a GeometryProxy via a GeometryReader. For not disturbing the design of views by enclosing some of them into a GeometryReader I generated one in the equatable function of the PreferenceData struct:
struct ParagraphSizeData: Equatable {
let paragraphRect: Anchor<CGRect>?
static func == (value1: ParagraphSizeData, value2: ParagraphSizeData) -> Bool {
var theResult : Bool = false
let _ = GeometryReader { geometry in
generateView(geometry:geometry, equality:&theResult)
}
func generateView(geometry: GeometryProxy, equality: inout Bool) -> Rectangle {
let paragraphSize1, paragraphSize2: NSSize
if let anAnchor = value1.paragraphRect { paragraphSize1 = geometry[anAnchor].size }
else {paragraphSize1 = NSZeroSize }
if let anAnchor = value2.paragraphRect { paragraphSize2 = geometry[anAnchor].size }
else {paragraphSize2 = NSZeroSize }
equality = (paragraphSize1 == paragraphSize2)
return Rectangle()
}
return theResult
}
}
With kind regards
It seems like the issue is not necessarily with ScrollView, but with your usage of PreferenceKey. For instance, here is a sample struct in which a PreferenceKey is set according to the width of a Rectangle, and then printed using .onPreferenceChange(), all inside of a ScrollView. As you drag the Slider to change the width, the key is updated and the print closure is executed.
struct ContentView: View {
#State private var width: CGFloat = 100
var body: some View {
VStack {
Slider(value: $width, in: 100...200)
ScrollView(.vertical) {
Rectangle()
.background(WidthPreferenceKeyReader())
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
.frame(width: self.width)
}
}
}
struct WidthPreferenceKeyReader: View {
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
As you noted, the first time the key tries to set, the console prints "Bound preference WidthPreferenceKey tried to update multiple times per frame," but a real value is immediately set afterward, and it continues to update dynamically.
What value are you actually trying to set, and what are you trying to do in .onPreferenceChange()?
I think this is because you implemented reduce() incorrectly.
You can find the details here:
https://stackoverflow.com/a/73300115/4366470
TL;DR: Replace value = nextValue() in reduce() with value += nextValue().
You may only read it in superView, but you can change it with transformPreference after you set it .
struct ContentView: View {
var body: some View {
ScrollView {
VStack{
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}.transformPreference(WidthPreferenceKey.self, {
$0 = 30})
}.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
The last value is 30 now. Hope it is what you want.
You can read from other layer:
ScrollView {
Text("Hello").preference(key: WidthPreferenceKey.self, value: CGFloat(40.0))
.backgroundPreferenceValue(WidthPreferenceKey.self) { x -> Color in
print(x)
return Color.clear
}
}
The problem here is actually not in ScrollView but in usage - this mechanism allow to transfer data up in viewTree:
A view with multiple children automatically combines its values for a
given preference into a single value visible to its ancestors.
source
The keywords here - with multiple children. This mean that u can pass it in viewTree from child to parent.
Let's review u'r code:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
.onPreferenceChange(WidthPreferenceKey.self) {
print($0) // Not being called, we're in a scroll view.
}
}
}
}
As u can see now - child pass value to itself, and not to parent - so this don't want to work, as per design.
And working case:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
Here, ScrollView is parent and Text is child, and child talk to parent - everything works as expected.
So, as I sad in the beginning the problem here not in ScrollView but in usage and in Apple documentation (u need to read it few times as always).
And regarding this:
Bound preference WidthPreferenceKey tried to update multiple times per
frame.
This is because u may change multiply values in same time and View can't be rendered, try to .receive(on:) or DispatchQueue.main.async as workaround (I guess this may be a bug)
This question is a bit of a follow on from SwiftUI: How to get continuous updates from Slider
Basically I have a slider which is one of a number of sliders. Each on changes a parameter on a model class so I'm passing in a binding which represents a specific property on the model class. This works in that the model gets the new value each time the slider moves.
struct AspectSlider: View {
private var value: Binding<Double>
init(value: Binding<Double>, hintKey: String) {
self.value = value
}
var body: some View {
VStack(alignment: .trailing) {
Text("\(self.value.value)")
Slider(value: Binding<Double>(getValue: { self.value.value }, setValue: { self.value.value = $0 }),
from: 0.0, through: 4.0, by: 0.5)
}
}
}
What isn't working correctly is the Text("\(self.value.value)") display which is meant to show the current value of the slider. It's not updating when the Binding<Double> value changes.
Instead it only updates when something else on the display triggers a display refresh. In my case the label that represents the result of the calculating performed by the model (which doesn't necessarily change when a slider changes it's value).
I've confirmed that the model is getting changes so the binding is updating. My question is why is the Text label not updating immediately.
Ok, I've worked out why my code wasn't updating as it should. It came down to my model which looks like this (Simple version):
final class Calculator: BindableObject {
let didChange = PassthroughSubject<Int, Never>()
var total: Int = 0
var clarity: Double = 0.0 { didSet { calculate() }}
private func calculate() {
if newValue.rounded() != Double(total) {
total = Int(newValue)
didChange.send(self.total)
}
}
}
What was happening was that the value on the sider was only updating when this model executed the didChange.send(self.total) line. I think, if I've got this right, that because the label is watching a binding, only when the binding updates does the label update. Makes sense. Still working this out.
I guess it part of learning about Combine and how it works :-)
Your code, if called like this, works fine:
struct ContentView: View {
#State private var value: Double = 0
var body: some View {
AspectSlider(value: $value, hintKey: "hint")
}
}
struct AspectSlider: View {
private var value: Binding<Double>
init(value: Binding<Double>, hintKey: String) {
self.value = value
}
var body: some View {
VStack(alignment: .trailing) {
Text("\(self.value.value)")
Slider(value: Binding<Double>(getValue: { self.value.value }, setValue: { self.value.value = $0 }),
from: 0.0, through: 4.0, by: 0.5)
}
}
}
Note that you can also take advantage of the property wrapper #Binding, to avoid using self.value.value. Your implementation should change slightly:
struct AspectSlider: View {
#Binding private var value: Double
init(value: Binding<Double>, hintKey: String) {
self.$value = value
}
var body: some View {
VStack(alignment: .trailing) {
Text("\(self.value)")
Slider(value: Binding<Double>(getValue: { self.value }, setValue: { self.value = $0 }),
from: 0.0, through: 4.0, by: 0.5)
}
}
}