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
Related
In SwiftUI, I've managed to make a Button animate right when the view is first drawn to the screen, using the animation(_:) modifier, that was deprecated in macOS 12.
I've tried to replace this with the new animation(_:value:) modifier, but this time nothing happens:
So this is not working:
struct ContentView: View {
#State var isOn = false
var body: some View {
Button("Press me") {
isOn.toggle()
}
.animation(.easeIn, value: isOn)
.frame(width: 300, height: 400)
}
}
But then this is working. Why?
struct ContentView: View {
var body: some View {
Button("Press me") {
}
.animation(.easeIn)
.frame(width: 300, height: 400)
}
}
The second example animates the button just as the view displays, while the first one does nothing
The difference between animation(_:) and animation(_:value:) is straightforward. The former is implicit, and the latter explicit. The implicit nature of animation(_:) meant that anytime ANYTHING changed, it would react. The other issue it had was trying to guess what you wanted to animate. As a result, this could be erratic and unexpected. There were some other issues, so Apple has simply deprecated it.
animation(_:value:) is an explicit animation. It will only trigger when the value you give it changes. This means you can't just stick it on a view and expect the view to animate when it appears. You need to change the value in an .onAppear() or use some value that naturally changes when a view appears to trigger the animation. You also need to have some modifier specifically react to the changed value.
struct ContentView: View {
#State var isOn = false
//The better route is to have a separate variable to control the animations
// This prevents unpleasant side-effects.
#State private var animate = false
var body: some View {
VStack {
Text("I don't change.")
.padding()
Button("Press me, I do change") {
isOn.toggle()
animate = false
// Because .opacity is animated, we need to switch it
// back so the button shows.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
animate = true
}
}
// In this case I chose to animate .opacity
.opacity(animate ? 1 : 0)
.animation(.easeIn, value: animate)
.frame(width: 300, height: 400)
// If you want the button to animate when the view appears, you need to change the value
.onAppear { animate = true }
}
}
}
Follow up question: animating based on a property of an object is working on the view itself, but when I'm passing that view its data through a ForEach in the parent view, an animation modifier on that object in the parent view is not working. It won't even compile. The objects happen to be NSManagedObjects but I'm wondering if that's not the issue, it's that the modifier works directly on the child view but not on the passed version in the parent view. Any insight would be greatly appreciated
// child view
struct TileView: View {
#ObservedObject var tile: Tile
var body: some View {
Rectangle()
.fill(tile.fillColor)
.cornerRadius(7)
.overlay(
Text(tile.word)
.bold()
.font(.title3)
.foregroundColor(tile.fillColor == .myWhite ? .darkBlue : .myWhite)
)
// .animation(.easeInOut(duration: 0.75), value: tile.arrayPos)
// this modifier worked here
}
}
struct GridView: View {
#ObservedObject var game: Game
let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 4)
var body: some View {
GeometryReader { geo in
LazyVGrid(columns: columns) {
ForEach(game.tilesArray, id: \.self) { tile in
Button(action: {
tile.toggleSelectedStatus()
moveTiles() <- this changes their array position (arrayPos), and
the change in position should be animated
}) {
TileView(tile: tile)
.frame(height: geo.size.height * 0.23)
}
.disabled(tile.status == .solved || tile.status == .locked)
.animation(.easeInOut(duration: 0.75), value: arrayPos)
.zIndex(tile.status == .locked ? 1 : 0)
}
}
}
}
}
Here's an interesting quandary: I want to make a timer that "ticks" reliably but, also, renders symbols in predictable places so that I could, for instance, decorate the timer by adding a background. Because of WidgetKit limitations, I cannot reliably render my own text every second and have to rely on special views, such as Text(Date(), style: .timer). However, this view can render time as, both, XX:XX and X:XX depending on how much time is left, which would be OK, except, it also, both, takes the whole width of the container and aligns to the left, which makes the last :XX move depending on time left.
Here's an illustration:
And code that produced it:
struct MyWidgetEntryView : View {
var body: some View {
VStack {
Text(Date().addingTimeInterval(1000), style: .timer)
.font(.body.monospacedDigit())
.background(Color.red)
Text(Date().addingTimeInterval(100), style: .timer)
.background(Color.green)
.font(.body.monospacedDigit())
}
}
}
Question: is there a way to make a reliably updating time display in a WidgetKit widget in such a way that symbols for minutes and seconds are always rendered in the same places and not move depending on time left?
I can't figure it out, please help me!
–Baglan
Set the multi-line text alignment:
Text(Date(), style: .timer)
.multilineTextAlignment(.trailing)
It’s not multiple lines of text but it works!
Still looking for a better answer, but here's a "proof of concept" hack to achieve my goal:
struct _T1WidgetEntryView : View {
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
#State private var digitSize: CGSize = .zero
#State private var semicolonSize: CGSize = .zero
var body: some View {
ZStack {
Text("0")
.overlay(
GeometryReader { proxy in
Color.green
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
.onPreferenceChange(SizePreferenceKey.self) { digitSize = $0 }
)
.hidden()
Text(":")
.overlay(
GeometryReader { proxy in
Color.green
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
.onPreferenceChange(SizePreferenceKey.self) { semicolonSize = $0 }
)
.hidden()
Color.clear
.frame(width: digitSize.width * 4 + semicolonSize.width, height: digitSize.width * 4 + semicolonSize.width)
.overlay(
Text(Date().addingTimeInterval(100 + 3600 * 200), style: .timer)
.frame(width: digitSize.width * 7 + semicolonSize.width * 2)
,
alignment: .trailing
)
.clipped()
}
.font(.body.monospacedDigit())
}
}
And the result is:
This code assumes that all the digits are the same width (hence the .monospacedDigit() font modifier).
Here's what it does:
Calculates the sizes of a digit symbol and the semicolon;
"Normalizes" the time string by adding 200 hours to ensure the XXX:XX:XX formatting;
Sets the size of the text to accommodate strings formatted as XXX:XX:XX;
Sets the size of the container to accommodate strings formatted as XX:XX;
Aligns the text .trailing in an overlay;
Clips the whole thing to the size of the container.
Again, if there is a better solution, I'd love to learn about it!
–Baglan
I have a 3-part picker, and I'm trying to make the values of one Picker to be based on the value of another. Specifically adding/removing the s on the end of "Days","Weeks",etc. I have read a similar post (here) on this type of situation, but the proposed Apple solution for IOS 14+ deployments is not working. Given that the other question focuses primarily on pre-14 solutions, I thought starting a new question would be more helpful.
Can anyone shed any light on why the .onChange is never getting called? I set a breakpoint there, and it is never called when the middle wheels value change between 1 and any other value as it should.
The unconventional init is just so I could encapsulate this code removed from a larger project.
Also, I have the .id for the 3rd picker commented out in the code below, but can un-comment if the only problem remaining is for the 3rd picker to update on the change.
import SwiftUI
enum EveryType:String, Codable, CaseIterable, Identifiable {
case every="Every"
case onceIn="Once in"
var id: EveryType {self}
var description:String {
get {
return self.rawValue
}
}
}
enum EveryInterval:String, Codable, CaseIterable, Identifiable {
case days = "Day"
case weeks = "Week"
case months = "Month"
case years = "Year"
var id: EveryInterval {self}
var description:String {
get {
return self.rawValue
}
}
}
struct EventItem {
var everyType:EveryType = .onceIn
var everyInterval:EveryInterval = .days
var everyNumber:Int = Int.random(in:1...3)
}
struct ContentView: View {
init(eventItem:Binding<EventItem> = .constant(EventItem())) {
_eventItem = eventItem
}
#Binding var eventItem:EventItem
#State var intervalId:UUID = UUID()
var body: some View {
GeometryReader { geometry in
HStack {
Picker("", selection: self.$eventItem.everyType) {
ForEach(EveryType.allCases)
{ type in Text(type.description)
}
}
.pickerStyle(WheelPickerStyle())
.frame(width: geometry.size.width * 0.3, height:100)
.compositingGroup()
.padding(0)
.clipped()
Picker("", selection: self.$eventItem.everyNumber
) {
ForEach(1..<180, id: \.self) { number in
Text(String(number)).tag(number)
}
}
//The purpase of the == 1 below is to only fire if the
// everyNumber values changes between being a 1 and
// any other value.
.onChange(of: self.eventItem.everyNumber == 1) { _ in
intervalId = UUID() //Why won't this ever happen?
}
.pickerStyle(WheelPickerStyle())
.frame(width: geometry.size.width * 0.25, height:100)
.compositingGroup()
.padding(0)
.clipped()
Picker("", selection: self.$eventItem.everyInterval) {
ForEach(EveryInterval.allCases) { interval in
Text("\(interval.description)\(self.eventItem.everyNumber == 1 ? "" : "s")")
}
}
.pickerStyle(WheelPickerStyle())
.frame(width: geometry.size.width * 0.4, height:100)
.compositingGroup()
.clipped()
//.id(self.intervalId)
}
}
.frame(height:100)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(eventItem: .constant(EventItem()))
}
}
For Picker, its item data type must conform Identifiable and we must pass a property of item into "tag" modifier as "id" to let Picker trigger selection and return that property in Binding variable with selection.
For example :
Picker(selection: $selected, label: Text("")){
ForEach(data){item in //data's item type must conform Identifiable
HStack{
//item view
}
.tag(item.property)
}
}
.onChange(of: selected, perform: { value in
//handle value of selected here (selected = item.property when user change selection)
})
//omg! I spent whole one day to find out this
Try the following
.onChange(of: self.eventItem.everyNumber) { newValue in
if newValue == 1 {
intervalId = UUID()
}
}
but it might also depend on how do you use this view, because with .constant binding nothing will change ever.
The answer by Thang Dang, above, turned out to be very helpful to me. I did not know how to conform my tag to Identifiable, but changed my tags from tag(1) to a string, as in the SwiftUI code below. The tag with a mere number in it caused nothing to happen when the Picker was set to Icosahedron (my breakpoint on setShape was never triggered), but the other three caused the correct shape to be passed in to setShape.
// set the current Shape
func setShape(value: String) {
print(value)
}
#State var shapeSelected = "Cube"
VStack {
Picker(selection: $shapeSelected, label: Text("$\(shapeSelected)")) {
Text("Cube").tag("Cube")
Text("Simplex").tag("Simplex")
Text("Pentagon (3D)").tag("Pentagon")
Text("Icosahedron").tag(1)
}.onChange(of: shapeSelected, perform: { tag in
setShape(value: "\(tag)")
})
}
I have view that can be dragged and dropped on top of other views (lets say categories). To detect which category view I'm on top of, I store their frames in a frames array, which happens in onAppear of their invisible overlays. (This is based on Paul Hudsons implementation in this tutorial).
This works all nice and well, except when the position of those views change, e.g. in device orientation or windows resizing on iPad. This of course doesn't trigger onAppear, so the frames don't match anymore.
HStack() {
ForEach(categories) { category in
ZStack {
Circle()
Rectangle()
.foregroundColor(.clear)
.overlay(
GeometryReader { geo in
Color.clear
.onAppear {
categoryFrames[index(for: category)] = geo.frame(in: .global)
}
}
)
}
}
}
So any idea how to update the frames in those instances or how to differently observe them would be welcome.
It is possible to read views frames dynamically during refresh using view preferences, so you don't care about orientation, because have actual frames every time view is redrawn.
Here is a draft of approach.
Introduce model for view preference key:
struct ItemRec: Equatable {
let i: Int // item index
let p: CGRect // item position frame
}
struct ItemPositionsKey: PreferenceKey {
typealias Value = [ItemRec]
static var defaultValue = Value()
static func reduce(value: inout Value, nextValue: () -> Value) {
value.append(contentsOf: nextValue())
}
}
and now your code (assuming #State private var categoryFrames = [Int, CGRect]())
HStack() {
ForEach(categories) { category in
ZStack {
Circle()
Rectangle()
.foregroundColor(.clear)
.background( // << prefer background to avoid any side effect
GeometryReader { geo in
Color.clear.preference(key: ItemPositionsKey.self,
value: [ItemRec(i: index(for: category), p: geo.frame(in: .global))])
}
)
}
}
.onPreferenceChange(ItemPositionsKey.self) {
// actually you can use this listener at any this view hierarchy level
// and possibly use directly w/o categoryFrames state
for item in $0 {
categoryFrames[item.i] = item.p
}
}
}
I had a similar problem and this post inspired me in finding a solution. So maybe this will be useful to someone else.
Just assign to the onChange modifier the same you did to onAppear and set it to fire when geo.size changes.
HStack() {
ForEach(categories) { category in
ZStack {
Circle()
Rectangle()
.foregroundColor(.clear)
.overlay(
GeometryReader { geo in
Color.clear
.onAppear {
categoryFrames[index(for: category)] = geo.frame(in: .global)
}
.onChange(of: geo.size) { _ in
categoryFrames[index(for: category)] = geo.frame(in: .global)
}
}
)
}
}
}
How can I make a parent view's width the width of the smallest child?
I can learn the width of the child view using the answer to this question like so:
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().fill(Color.clear).preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
struct Content: View {
#State var width1: CGFloat = 0
#State var width2: CGFloat = 0
var body: some View {
VStack {
Text("Short")
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.width1 = $0 })
Text("Loooooooooooong")
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.width2 = $0 })
}
.frame(width: min(self.width1, self.width2)) // This is always 0
}
}
But the VStack has always width 0. It's like as if
the line .frame(width: min(self.width1, self.width2)) is being called
before the widths are set.
Any ideas?
This is a logic bug, in that the system is technically doing exactly what you've told it to, but that result is not what you, as the programmer, intended.
Basically, VStack wants to shrink as much as possible to fit the size of its content. Likewise, Text views are willing to shrink as much as possible (truncating their contents) to fit inside their parent view. The only hard requirement you've given is that the initial frame of the VStack have width of 0. So the following sequence happens:
width1 and width2 are initialized to 0
The VStack sets its width to min(0, 0)
The Text views inside shrink to width 0
WidthPreferenceKey.self is set to 0
.onPreferenceChange sets width1 and width2, which are already 0
All the constraints are satisfied, and SwiftUI happily stops layout.
Let's modify your code with the following:
Make WidthPreferenceKey.Value a typealias to [CGFloat] instead of CGFloat. This way, you can set as many preference key setters as you want, and they will just keep accumulating into an array.
Use one .onPreferenceChange call, which will find the minimum of all the read values, and set the single #State var width: CGFloat property
Add .fixedSize() to the Text views.
Like so:
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = [CGFloat]()
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
typealias Value = [CGFloat]
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle()
.fill(Color.clear)
.preference(key: WidthPreferenceKey.self,
value: [geometry.size.width])
}
}
}
struct Content: View {
#State var width: CGFloat = 0
var body: some View {
VStack {
Text("Short")
.fixedSize()
.background(TextGeometry())
Text("Loooooooooooong")
.fixedSize()
.background(TextGeometry())
}
.frame(width: self.width)
.clipped()
.background(Color.red.opacity(0.5))
.onPreferenceChange(WidthPreferenceKey.self) { preferences in
print(preferences)
self.width = preferences.min() ?? 0
}
}
}
Now the following happens:
width is initialized to 0
The VStack sets its width to 0
The Text views expand outside the VStack, since we've given permission with .fixedSize()
WidthPreferenceKey.self is set to [42.5, 144.5] (or something close)
.onPreferenceChange sets width to 42.5
The VStack sets its width to 42.5 to satisfy its .frame() modifier.
All constraints are satisfied, and SwiftUI stops layout. Note that .clipped() is keeping the edges of the long Text view from displaying, even though they are technically outside the bounds of the VStack.