I've been trying to get changes to my list elements to animate correctly. However, items in a list don't seem to animate as specified.
In this simple example, an element is removed. There is an animation, within 1 second the element is removed. However, it completely ignores the duration and delay.
struct ContentView: View {
#State var items = [1, 2, 3, 4, 5]
var body: some View {
VStack {
List {
ForEach(items, id: \.self) { item in
Text("Item \(item)")
}
}
Button {
withAnimation(Animation.easeInOut(duration: 5).delay(1)) {
print("removing element")
items.removeFirst()
}
} label: {
Text("Remove element")
}
}
}
}
If I remove the List and just have a VStack of items, the Animation parameter is processed correctly.
If I remove the withAnimation, it doesn't animate at all. So it is triggering it.
I would say that this is expected as this is how underlying UITableViewController is working, it doesn't have capabilities to customise animations that way.
I think that if you want to do something custom you need to start with LazyVStack in ScrollView, that will give you more space for creation and it is quite likely that this would work (I haven't tried it, but from the logical point it should).
Related
I'm developing an app, one view of which has the primary goal of allowing users to reorder a List of NavigationLinks, but which I would also like to allow navigation & a few other things. I want users to be able to:
Reorder by dragging on the reorder control.
Navigate to the link by tapping elsewhere on the row.
Swipe from the leading edge to activate a swipe action.
At the moment, enabling EditMode (to allow reordering) disables navigation & swipe actions; I haven't been able to find a workaround that allows all 3 functions simultaneously. Is there a good way to do this?
Here's an example:
struct ReorderableListView: View {
var items: [X] // List of custom objects
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: CustomView(item)) { // winds up disabled by edit mode
Text(item.name)
}
.swipeActions(edge: .leading) { Button("Swipe") {print("swipe")} } // winds up disabled by edit mode
}
.onMove { from, to in
print("move \(from) to \(to)")
}
}
.environment(\.editMode, .constant(.active)) // always in edit mode for reordering
}
}
}
Update:
Based on the accepted answer, it looks like this behavior got updated in iOS 16: Now, if onMove is implemented, you can reorder with a long press even when not in edit mode, which allows these three actions to coexist. I've added custom drag-handles to make this behavior obvious to the user, but otherwise I'm going with exactly the solution given in the accepted answer, below.
I am not sure that the .environment is necessary in this case (I could be wrong). You can remove that piece.
Additionally, you should add an ID to each item in your foreach. This should ideally come from your model when you create new items (for example, your model can contain an ID variable = UUID()), but for the time being we can add it inline in your foreach.
I had to write some code on my end to get this up and running, so my solution is based on the code I spun up (very similar to yours, but missing your custom items object):
struct ReorderableListView: View {
var items: [X] // List of custom objects
var body: some View {
NavigationView {
List {
// added ID here
ForEach(items, id: \.self) { item in
NavigationLink(destination: CustomView(item)) { // winds up disabled by edit mode
Text(item.name)
}
.swipeActions(edge: .leading) { Button("Swipe") {print("swipe")} } // winds up disabled by edit mode
}
.onMove { from, to in
print("move \(from) to \(to)")
}
}
// REMOVED .environment(\.editMode, .constant(.active)) // always in edit mode for reordering
}
}
}
For reference, here is the code I wrote locally to fill in the gaps. This is what worked & what I implemented within your code:
struct ReorderableListView: View {
#State var items = [1, 2, 3] // List of custom objects
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) { item in
NavigationLink(destination: ContentView2()) { // winds up disabled by edit mode
Text("\(item)")
}
.swipeActions(edge: .leading) { Button("Swipe") {print("swipe")} } // winds up disabled by edit mode
}
.onMove { from, to in
print("move \(from) to \(to)")
}
}
// .environment(\.editMode, .constant(.active)) // always in edit mode for reordering
}
}
}
I'm new to Swift development, and I'm trying to make a View, where you can click an item and it gets bigger, while the old big item gets smaller. I'm using an #State var called chosen to know which Element should be big at the moment. The items itself are Views with a Button on top. The idea is, that I click the button and the button will change the chosen variable, which is working. But it seems that my view doesn't redraw itself and everything stays as is. The simplified pseudocode looks like this:
struct MyView: View {
#State var chosen = 0
var body: some View {
VStack(){
ForEach(0 ..< 4) { number in
if self.chosen == number {
DifferentView()
.frame(big)
.clipShape(big)
}else{
ZStack{
DifferentView()
.frame(small)
.clipShape(small)
Button(action: {self.chosen = number}){Rectangle()}
}
}
}
}
}
You're using this overload of ForEach.init(_:content:), which accepts a constant range. While your range doesn't change, it also appears to be that this ForEach variant doesn't update the content (it was surprising to me).
You need to use the following overload: ForEach.init(_:id:content:) - supplying id with a keypath:
ForEach(0 ..< 4, id: \.self) { number in
// ...
}
But because there is a conditional, it trips up SwiftUI (hard to know why). The way to avoid it is to wrap it in something, like a Group or a ZStack, or even a function that generates the inner view:
ForEach(0 ..< 4, id: \.self) { number in
Group {
self.chosen == number {
// ...
} else {
// ...
}
}
}
Or, like so:
ForEach(0 ..< 4, id: \.self) { number in
self.inner(for: number)
}
#ViewBuilder
func inner(for number: Int) -> some View {
self.chosen == number {
// ...
} else {
// ...
}
}
I want to use NavigationView together with the ScrollView, but I am not seeing List items.
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView{
VStack {
Text("Some stuff 1")
List{
Text("one").padding()
Text("two").padding()
Text("three").padding()
}
Text("Some stuff 2")
}
}
}
}
}
All I see is the text. If I remove ScrollView I see it all, but the text is being pushed to the very bottom. I simply want to be able to add List and Views in a nice scrollable page.
The ScrollView expects dimension from content, but List expects dimension from container - as you see there is conflict, so size for list is undefined, and a result rendering engine just drop it to avoid disambiguty.
The solution is to define some size to List, depending of your needs, so ScrollView would now how to lay out it, so scroll view could scroll entire content and list could scroll internal content.
Eg.
struct ContentView: View {
#Environment(\.defaultMinListRowHeight) var minRowHeight
var body: some View {
NavigationView {
ScrollView{
VStack {
Text("Some stuff 1")
List {
Text("one").padding()
Text("two").padding()
Text("three").padding()
}.frame(minHeight: minRowHeight * 3).border(Color.red)
Text("Some stuff 2")
}
}
}
}
}
Just wanted to throw out an answer that fixed what I was seeing very similar to the original problem - I had put a Label() item ahead of my List{ ... } section, and when I deleted that Label() { } I was able to see my List content again. Possibly List is buggy with other items surrounding it (Xcode 13 Beta 5).
I recently started studying ios/swiftui by developing a small application, which has a list of cards to display picture and message loaded from server.
I was using List to hold those cards but the default list 'decoration' (divider, arrow, tapping effect) looks not very good together with my card view. I disable them by adding below code into SceneDelegate:
UITableView.appearance().separatorColor = .clear
UITableViewCell.appearance().selectionStyle = .none
And having some sort of hack to hide the arrow:
List {
ForEach(0..<self.store.data.count, id: \.self) { idx in
NavigationLink(destination: DetailView(item: self.store.data[idx])) {
EmptyView()
}.frame(width: 0).opacity(0)
ItemCard(item: self.store.data[idx])
}
BottomLoader().onAppear {
if self.store.status != .loading {
self.store.load()
}
}
}
But, the problem is that hiding List's selection style and separator color in SceneDelegate applies to all the lists in App, and I do have couple lists (such as the one in settings) need those style.
Then I tried to change the card holder from List to ScrollView but it leads to another trouble, the onAppear callback of last view (BottomLoader) gets called at the same time with ScrollView onAppear gets called.
As far as I understand, onAppear is supposed to be called when view is rendered and shown. List as the item holder, does not have to calculate the full height of all items, that is probably why List renders only the items about to enter screen. But ScrollView does need to know the full height of all items and that is why all the items get rendered.
Below are two small segments to show different behaviors of List and ScrollView:
List {
ForEach(0..<100) { idx in
Text("item # \(idx)").padding().onAppear { print("\(idx) on appear") }
}
}
// only 18 logs printed
And:
ScrollView {
ForEach(0..<100) { idx in
Text("item # \(idx)").padding().onAppear { print("\(idx) on appear") }
}
}
// all 100 logs printed
Is there any way, to let ScrollView acts like List, to render its subviews when they about to enter screen.
Or, is there any way, to disable those default styles to a specific List, not globally?
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)