Swift UI ForEach should only be used for *constant* data - ios

So i get this error when running my set game app:
Image of the game screen when more of the same cards appear (All cards in the game should be unique)
ForEach<Range<Int>, Int, ModifiedContent<_ConditionalContent<_ConditionalContent<_ConditionalContent<_ConditionalContent
<ZStack<TupleView<(_ShapeView<Squiggle, ForegroundStyle>,
_StrokedShape<Squiggle>)>>, ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>,
_ClipEffect<Squiggle>>, _StrokedShape<Squiggle>)>>>, _StrokedShape<Squiggle>>,
_ConditionalContent<_ConditionalContent<ZStack<TupleView<(_ShapeView<Capsule, ForegroundStyle>, _StrokedShape<Capsule>)>>,
ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>, _ClipEffect<Capsule>>,
_StrokedShape<Capsule>)>>>, _StrokedShape<Capsule>>>, _ConditionalContent<_ConditionalContent<ZStack<TupleView<(_ShapeView<Diamond,
ForegroundStyle>, _StrokedShape<Diamond>)>>, ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>, _ClipEffect<Diamond>>,
_StrokedShape<Diamond>)>>>, _StrokedShape<Diamond>>>,
_FrameLayout>> count (2) != its initial count (1). `ForEach(_:content:)`
should only be used for *constant* data. Instead conform data to `Identifiable`
or use `ForEach(_:id:content:)` and provide an explicit `id`!
It doesnt seem like the card models underneath the view themselves change. Everything but the numberOfShapes on the cards stay the same. So a card that is displayed wrongly will only match if the underlying model is matched correctly and will not match like shown on view.
The error only seem to appear when i either get a set correct and press "show three more cards" or when i display more cards than is visible and then scroll up and down the screen.
The app doesnt stop with the error, but the numberOfShapes on each card changes dynamically (Like on the screenshot above). (This is most clear when i scroll to the top of the screen and then to the bottom and the number of shapes on some cards changes each time)
So i think the problem might be in the card view:
import SwiftUI
struct CardView: View {
var card: Model.Card
var body: some View {
GeometryReader { geometry in
ZStack{
let shape = RoundedRectangle(cornerRadius: 10)
if !card.isMatched {
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: 4).foregroundColor(card.isSelected ? .red : .blue)
}
VStack {
Spacer(minLength: 0)
//TODO: Error here?
ForEach(0..<card.numberOfShapes.rawValue) { index in
cardShape().frame(height: geometry.size.height/6)
}
Spacer(minLength: 0)
}.padding()
.foregroundColor(setColor())
.aspectRatio(CGFloat(6.0/8.0), contentMode: .fit)
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
#ViewBuilder private func cardShape() -> some View {
switch card.shape {
case .squiglle: shapeFill(shape: Squiggle())
case .roundedRectangle: shapeFill(shape: Capsule())
case .diamond: shapeFill(shape: Diamond())
}
}
private func setColor() -> Color {
switch card.color {
case .pink: return Color.pink
case .orange: return Color.orange
case .green: return Color.green
}
}
#ViewBuilder private func shapeFill<setShape>(shape: setShape) -> some View
//TODO: se løsning, brug rawvalues for cleanness
where setShape: Shape {
switch card.fill {
case .fill: shape.fillAndBorder()
case .stripes: shape.stripe()
case .none: shape.stroke(lineWidth: 2)
}
}
}
The cardviews are displayed in a flexible gridlayout:
import SwiftUI
struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
var items: [Item]
var aspectRatio: CGFloat
var content: (Item) -> ItemView
let maxColumnCount = 6
init(items: [Item], aspectRatio: CGFloat, #ViewBuilder content: #escaping (Item) -> ItemView) {
self.items = items
self.aspectRatio = aspectRatio
self.content = content
}
var body: some View {
GeometryReader { geometry in
VStack {
let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
ScrollView{
LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
ForEach(items) { item in
content(item).aspectRatio(aspectRatio, contentMode: .fit)
}
Spacer(minLength: 0)
}
}
}
}
}
private func adaptiveGridItem(width: CGFloat) -> GridItem {
var gridItem = GridItem(.adaptive(minimum: width))
gridItem.spacing = 0
return gridItem
}
View:
import SwiftUI
struct SetGameView: View {
#StateObject var game: ViewModel
var body: some View {
VStack{
HStack(alignment: .bottom){
Text("Set Game")
.font(.largeTitle)
}
AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
CardView(card: card)
.padding(4)
.onTapGesture {
game.choose(card)
}
}.foregroundColor(.blue)
.padding(.horizontal)
Button("show 3 more cards"){
game.showThreeMoreCardsFromDeck()
}
}
}
}
Any help solving this is very much appreciated. I have spent so much time on this already.

Your problem is here:
ForEach(0..<card.numberOfShapes.rawValue) { index in
cardShape().frame(height: geometry.size.height/6)
}
This ForEach initializer's first argument is a Range<Int>. The documentation says (emphasis added):
The instance only reads the initial value of the provided data and doesn’t need to identify views across updates. To compute views on demand over a dynamic range, use ForEach/init(_:id:content:).
Because it only reads “the initial value of the provided data”, it's only safe to use if the Range<Int> is constant. If you change the range, the effect is unpredictable. So, starting in Xcode 13, the compiler emits a warning if you pass a non-constant range.
Now, maybe your range (0..<card.numberOfShapes.rawValue) really is constant. But the compiler doesn't know that. (And based on your error, it's not a constant range.)
You can avoid the warning by switching to the initializer that takes an id: argument:
ForEach(0..<card.numberOfShapes.rawValue, id: \.self) { index in
// ^^^^^^^^^^^^^
// add this
cardShape().frame(height: geometry.size.height/6)
}

Related

ForEach nested in List - stop producing rows when row count hits particular point

I have a list with a nested ForEach loop that should take the data and stop producing rows once the row counter hits three, but I'm receiving the following error message
Type '()' cannot conform to 'View
Is it possible to cut off the loop once we have three rows displaying? My end goal is once three rows are displayed, to show a "View More" button.
import SwiftUI
struct RecipeListView: View {
#State var listofRecipes: [RecipeListModel] = RecipeList.recipes
#State private var recipeCounter = 0
init(){
UITableView.appearance().backgroundColor = .clear
}
var body: some View {
VStack{
Text("Recipes")
.padding(.bottom, -20)
//.padding(.top, 40)
.font(.title2)
List{
ForEach(listofRecipes, id: \.id){ recipe in
recipeCounter += 1
if(recipeCounter < 3){
HStack{
Image(recipe.image)
.resizable()
.frame (width: 70, height:70)
.cornerRadius(15)
VStack{
Text(recipe.name)
.font(.title2)
//temp solution until I can center it
.padding(.trailing, 25)
NavigationLink(destination: {RecipeController(name: recipe.name, image: recipe.image)}, label: {
Text("View Recipe")
.padding(5)
.foregroundColor(.black)
.background(Color("completeGreen"))
.frame(maxWidth: .infinity)
.cornerRadius(14)
})
.padding(4)
}
}
}
}
}
}
}
}
Your type cannot conform to view because you can't change the variables in the view. You can declare let, use if or switch, but you can't declare a var or change the value directly - so, recipeCounter += 1 is not allowed.
One solution is to create a temporary list that contains only the first 3 items, then change it to the full list after the user decides to see the rest.
The example below has some explanations of what each part of the code does:
struct Example: View {
// The full list is a let constant
let fullList = [Recipe("First"),
Recipe("Second"),
Recipe("Third"),
Recipe("Fourth"),
Recipe("Fifth")]
// The list is a #State variable: .onAppear will set
// it to contain the first 3 items, then it'll change
// to the full list
#State private var listofRecipes = [Recipe]()
var body: some View {
VStack{
Text("Recipes")
.font(.title2)
.padding()
List{
ForEach(listofRecipes, id: \.id){ recipe in
Text(recipe.text)
}
}
// "Show more" button: will change the list
// to make it contain all the items
Button {
withAnimation {
listofRecipes = fullList
}
} label: {
Text("Show more")
}
}
// When the view appears, set the list to contain only the
// first 3 items
.onAppear {
// Handle case when the list contains only 3 elements or less
if fullList.count > 3 {
listofRecipes = fullList.dropLast(fullList.count - 3)
}
}
}
struct Recipe {
let id = UUID()
let text: String
init(_ text: String) {
self.text = text
}
}
}
As #HunterLion mentioned, the problem is caused by recipeCounter += 1.
Another way to solve this is to use the list index directly in your condition:
ForEach(listofRecipes.indices) { index in
if(index < 3) {
HStack {
Image(listofRecipes[index].image)
....
Another alternative to what the two others said is to use the .prefix modifier on listofRecipes. This will return a subsequence of your array with the first n elements.
ForEach(listofRecipes.prefix(3)) { recipe in
HStack {
...

How to perform action only once per item in an array (SwiftUI)?

I have an array that holds 2 different SwiftUI view types (soon to be 6 different types) that I need to iterate an action only once over. They are different types of dice for tabletop RPGs (D4, D6, D20, etc) that each have a function within the struct of the views to display a random number within a Text field. The issue I'm running into is that I have a button that should trigger those functions once per view, but instead it sets the same number on all of the views.
The relevant code is below, please note any [...] are where I've condensed code that isn't relevant. Thank you!
Here is the struct for the D6DiceView, it contains the function for the random roll (this is the full current view)
struct D6DiceView: View {
#Binding var rolledValue: String
var body: some View {
VStack {
ZStack {
RoundedRectangle (cornerRadius: 10)
.frame(width: 100, height: 100)
.foregroundColor(.red)
Text(rolledValue)
.font(.title)
.foregroundColor(.white)
}
}
}
func d6rollButtonPressed() {
let randomInt = Int.random(in: 1..<7)
rolledValue = "\(randomInt)"
print("Random number is \(randomInt)")
}
}
This is the "main" view where all the dice would be held/
I'm only trying to get the D6DiceView working, but there will be multiple different dice views after this is working.
Here is the array and initial value for each die
struct DiceSelectView: View {
#State var viewArray = [] as [Any]
#State var initialRolledValue = "1"
...
}
Here is where the views are displayed
LazyHGrid (rows: selectedDiceColumns) {
ForEach(0..<viewArray.count, id: \.self) { index in
if self.viewArray[index] is D4DiceView {
D4DiceView()
.onTapGesture {
withAnimation(.spring()) {
self.viewArray.remove(at:index)
print("D4 tapped")
}
}
} else {
D6DiceView(rolledValue: $initialRolledValue)
.onTapGesture {
withAnimation(.spring()) {
self.viewArray.remove(at:index)
print("D6 tapped")
}
}
}
}
}
Here is the button that is pressed to trigger the dice roll
VStack {
Button(action: rollButtonPressed, label: {
Text("Roll Dice")
.foregroundColor(.white)
.padding()
.frame(width: 120, height: 40)
.background(Color(.systemIndigo))
.cornerRadius(10)
})
}
Here is the function that actions on the button press, "rolling" just the D6DiceViews
func rollButtonPressed() {
let d6Array = viewArray.compactMap {$0 as? D6DiceView}
d6Array.forEach { item in
item.d6rollButtonPressed()
}
}

Is there a way to define the width of my SwiftUI Image to be relative to screen size using Geometry Reader?

I am working through a sample app to familiarize myself with Swift and SwiftUI and am wanting to combine the ideas of composite layouts using custom views and GeometryReader to determine the screen-size of the device.
The page I'm trying to build is a scrollable list of friends where each row item is a button that can be selected to expand the list to see more details of each friend. I've built the button and all containing views as a FriendView to use inside of my FriendListView. I have defined 2 GeometryReader views within the FriendListView but do not know how I can define the size of each row from within the FriendView to make it maintain the appropriate size.
The need for this comes from not every image that could be set by a user later on for their friend will have the same dimensions so I want to keep it consistent. I understand I could set a .frame() modifier and hardcode the width but I would like it to be relational to the screen to be consistent across all phone screens.
Example: When a row is not expanded, the image should take up half of the device width. When it is expanded I would want it to take up one quarter of the device screen width.
Screenshot
Application View
Sample Code
struct FriendListView: View {
var pets = ["Woofer", "Floofer", "Booper"]
var body: some View {
GeometryReader { geo1 in
NavigationView {
GeometryReader { geo2 in
List(self.pets, id: \.self) { pet in
FriendView(name: pet)
}// End of List
}
.navigationBarTitle(Text("Furiends"))
}// End of NavigationView
}// End of GeometryReader geo1
}// End of body
}
struct FriendView: View {
#State private var isExpanded = false
var randomPic = Int.random(in: 1...2)
var name = ""
var breed = "Dawg"
var body: some View {
Button(action: self.toggleExpand) {
HStack {
VStack {
Image("dog\(self.randomPic)")
.resizable()
.renderingMode(.original)
.scaledToFill()
.clipShape(Circle())
if self.isExpanded == false {
Text(self.name)
.font(.title)
.foregroundColor(.primary)
}
}
if self.isExpanded == true {
VStack {
Text(self.name).font(.title)
Text(self.breed).font(.headline)
}
.foregroundColor(.primary)
}
}
}// End of Button
}
func toggleExpand() {
isExpanded.toggle()
}
}
You can define a GeometryReader in the top level FriendListView and pass the screen width to the FriendView:
struct FriendListView: View {
var pets = ["Woofer", "Floofer", "Booper"]
var body: some View {
GeometryReader { geo in
NavigationView {
List(self.pets, id: \.self) { pet in
FriendView(name: pet, screenWidth: geo.size.width)
}
.navigationBarTitle(Text("Furiends"))
}
}
}
}
Then use the .frame to set the maximum width for an image.
struct FriendView: View {
...
var screenWidth: CGFloat
var imageWidth: CGFloat {
isExpanded ? screenWidth / 4 : screenWidth / 2
}
var body: some View {
Button(action: self.toggleExpand) {
HStack {
VStack {
Image("dog\(self.randomPic)")
.resizable()
.renderingMode(.original)
.scaledToFill()
.clipShape(Circle())
.frame(width: imageWidth, height: imageWidth) // <- add frame boundaries
if self.isExpanded == false {
Text(self.name)
.font(.title)
.foregroundColor(.primary)
}
}
if self.isExpanded == true {
VStack {
Text(self.name).font(.title)
Text(self.breed).font(.headline)
}
.foregroundColor(.primary)
}
}
}
}
func toggleExpand() {
isExpanded.toggle()
}
}
In case you'd want your picture to be centered you can use Spacer:
HStack {
Spacer()
FriendView(name: pet, screenWidth: geo.size.width)
Spacer()
}

Stanford CS193p - Changing font size in line with card count number?

I'm on the final task for Assignment 1, "5. When your game randomly shows 5 pairs, the font we are using for the emoji will be too large (in portrait) and will start to get clipped. Have the font adjust in the 5 pair case (only) to use a smaller font than .largeTitle. Continue to use .largeTitle when there are 4 or fewer pairs in the game."
I've added the following code below I made, but cant figure out HOW to do it correctly. Any help greatly appreciated. New to coding for those super enough to answer!
struct ContentView: View {
var viewModel: EmojiMemoryGame
var body: some View {
HStack {
ForEach(viewModel.cards) { card in
CardView(card: card).onTapGesture {
self.viewModel.choose(card: card)
}
// MARK: - Aspect Ratio 2/3
}.aspectRatio(2/3, contentMode: .fit)
}
.padding()
.foregroundColor(Color.orange)
//.font(Font.largeTitle). To be deleted once the below is made successful
//TODO: - Add font size to allow 5 pairs of cards not to clip on portrait view
if numberOfPairsOfCards.count >=5 {
.font(Font.footnote)
} else {
.font(Font.largeTitle)
}
I used the ternary operator
struct ContentView: View {
var viewModel: EmojiMemoryGame
var body: some View {
HStack {
ForEach(viewModel.cards) { card in
CardView(card: card).onTapGesture {
self.viewModel.choose(card: card)
}
.aspectRatio(2/3, contentMode: .fit)
}
}
.padding()
.foregroundColor(Color.orange)
.font(viewModel.cards.count == 10 ? Font.footnote : Font.largeTitle)
}}
So what I did was adding a parameter when initializing the ContentView, since I found that many things are not mutable inside the contentView. So I had a line in the SceneDelagate to handle the font type
let game = EmojiMemoryGame()
var fontType: Font
if game.cards.count == 10{
fontType = Font.body
} else {
fontType = Font.largeTitle
}
let contentView = ContentView(game_viewModel: game, fontType: fontType)
And here's my ContentView Code
struct ContentView: View {
var game_viewModel: EmojiMemoryGame
var fontType: Font
var body: some View {
let cardStack = HStack {
ForEach(game_viewModel.cards) { card in
CardView(card: card).onTapGesture{
self.game_viewModel.choose(card: card)
}
}
}
.padding()
.foregroundColor(Color.orange)
.font(fontType)
return cardStack
}
}
Another way of doing it is by adding a function to update fonts:
struct ContentView: View {
var game_viewModel: EmojiMemoryGame
func updatefont() -> Font{
if game_viewModel.cards.count == 10{
return Font.body
} else {
return Font.largeTitle
}
}
var body: some View {
let cardStack = HStack {
ForEach(game_viewModel.cards) { card in
CardView(card: card).onTapGesture{
self.game_viewModel.choose(card: card)
}
}
}
.padding()
.foregroundColor(Color.orange)
.font(updatefont())
return cardStack
}
}

How to scroll List programmatically in SwiftUI?

It looks like in current tools/system, just released Xcode 11.4 / iOS 13.4, there will be no SwiftUI-native support for "scroll-to" feature in List. So even if they, Apple, will provide it in next major released, I will need backward support for iOS 13.x.
So how would I do it in most simple & light way?
scroll List to end
scroll List to top
and others
(I don't like wrapping full UITableView infrastructure into UIViewRepresentable/UIViewControllerRepresentable as was proposed earlier on SO).
SWIFTUI 2.0
Here is possible alternate solution in Xcode 12 / iOS 14 (SwiftUI 2.0) that can be used in same scenario when controls for scrolling is outside of scrolling area (because SwiftUI2 ScrollViewReader can be used only inside ScrollView)
Note: Row content design is out of consideration scope
Tested with Xcode 12b / iOS 14
class ScrollToModel: ObservableObject {
enum Action {
case end
case top
}
#Published var direction: Action? = nil
}
struct ContentView: View {
#StateObject var vm = ScrollToModel()
let items = (0..<200).map { $0 }
var body: some View {
VStack {
HStack {
Button(action: { vm.direction = .top }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { vm.direction = .end }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
ScrollViewReader { sp in
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { item in
VStack(alignment: .leading) {
Text("Item \(item)").id(item)
Divider()
}.frame(maxWidth: .infinity).padding(.horizontal)
}
}.onReceive(vm.$direction) { action in
guard !items.isEmpty else { return }
withAnimation {
switch action {
case .top:
sp.scrollTo(items.first!, anchor: .top)
case .end:
sp.scrollTo(items.last!, anchor: .bottom)
default:
return
}
}
}
}
}
}
}
}
SWIFTUI 1.0+
Here is simplified variant of approach that works, looks appropriate, and takes a couple of screens code.
Tested with Xcode 11.2+ / iOS 13.2+ (also with Xcode 12b / iOS 14)
Demo of usage:
struct ContentView: View {
private let scrollingProxy = ListScrollingProxy() // proxy helper
var body: some View {
VStack {
HStack {
Button(action: { self.scrollingProxy.scrollTo(.top) }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { self.scrollingProxy.scrollTo(.end) }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
List {
ForEach(0 ..< 200) { i in
Text("Item \(i)")
.background(
ListScrollingHelper(proxy: self.scrollingProxy) // injection
)
}
}
}
}
}
Solution:
Light view representable being injected into List gives access to UIKit's view hierarchy. As List reuses rows there are no more values then fit rows into screen.
struct ListScrollingHelper: UIViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeUIView(context: Context) -> UIView {
return UIView() // managed by SwiftUI, no overloads
}
func updateUIView(_ uiView: UIView, context: Context) {
proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
}
}
Simple proxy that finds enclosing UIScrollView (needed to do once) and then redirects needed "scroll-to" actions to that stored scrollview
class ListScrollingProxy {
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: UIScrollView?
func catchScrollView(for view: UIView) {
if nil == scrollView {
scrollView = view.enclosingScrollView()
}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentSize.height +
scroller.contentInset.bottom + scroller.contentInset.top - 1
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
scroller.scrollRectToVisible(rect, animated: true)
}
}
}
extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}
Just scroll to the id:
scrollView.scrollTo(ROW-ID)
Since SwiftUI structured designed Data-Driven, You should know all of your items IDs. So you can scroll to any id with ScrollViewReader from iOS 14 and with Xcode 12
struct ContentView: View {
let items = (1...100)
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
ForEach(items, id: \.self) { Text("\($0)"); Divider() }
}
HStack {
Button("First!") { withAnimation { scrollProxy.scrollTo(items.first!) } }
Button("Any!") { withAnimation { scrollProxy.scrollTo(50) } }
Button("Last!") { withAnimation { scrollProxy.scrollTo(items.last!) } }
}
}
}
}
Note that ScrollViewReader should support all scrollable content, but now it only supports ScrollView
Preview
Preferred way
This answer is getting more attention, but I should state that the ScrollViewReader is the right way to do this. The introspect way is only if the reader/proxy doesn't work for you, because of a version restrictions.
ScrollViewReader { proxy in
ScrollView(.vertical) {
TopView().id("TopConstant")
...
MiddleView().id("MiddleConstant")
...
Button("Go to top") {
proxy.scrollTo("TopConstant", anchor: .top)
}
.id("BottomConstant")
}
.onAppear{
proxy.scrollTo("MiddleConstant")
}
.onChange(of: viewModel.someProperty) { _ in
proxy.scrollTo("BottomConstant")
}
}
The strings should be defined in one place, outside of the body property.
Legacy answer
Here is a simple solution that works on iOS13&14:
Using Introspect.
My case was for initial scroll position.
ScrollView(.vertical, showsIndicators: false, content: {
...
})
.introspectScrollView(customize: { scrollView in
scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
})
If needed the height may be calculated from the screen size or the element itself.
This solution is for Vertical scroll. For horizontal you should specify x and leave y as 0
Thanks Asperi, great tip. I needed to have a List scroll up when new entries where added outside the view. Reworked to suit macOS.
I took the state/proxy variable to an environmental object and used this outside the view to force the scroll. I found I had to update it twice, the 2nd time with a .5sec delay to get the best result. The first update prevents the view from scrolling back to the top as the row is added. The 2nd update scrolls to the last row. I'm a novice and this is my first stackoverflow post :o
Updated for MacOS:
struct ListScrollingHelper: NSViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeNSView(context: Context) -> NSView {
return NSView() // managed by SwiftUI, no overloads
}
func updateNSView(_ nsView: NSView, context: Context) {
proxy.catchScrollView(for: nsView) // here NSView is in view hierarchy
}
}
class ListScrollingProxy {
//updated for mac osx
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: NSScrollView?
func catchScrollView(for view: NSView) {
//if nil == scrollView { //unB - seems to lose original view when list is emptied
scrollView = view.enclosingScrollView()
//}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentView.frame.minY
if let documentHeight = scroller.documentView?.frame.height {
rect.origin.y = documentHeight - scroller.contentSize.height
}
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
//tried animations without success :(
scroller.contentView.scroll(to: NSPoint(x: rect.minX, y: rect.minY))
scroller.reflectScrolledClipView(scroller.contentView)
}
}
}
extension NSView {
func enclosingScrollView() -> NSScrollView? {
var next: NSView? = self
repeat {
next = next?.superview
if let scrollview = next as? NSScrollView {
return scrollview
}
} while next != nil
return nil
}
}
my two cents for deleting and repositioning list at any point based on other logic.. i.e. after delete/update, for example going to top.
(this is a ultra-reduced sample, I used this code after network call back to reposition: after network call I change previousIndex )
struct ContentView: View {
#State private var previousIndex : Int? = nil
#State private var items = Array(0...100)
func removeRows(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
self.previousIndex = offsets.first
}
var body: some View {
ScrollViewReader { (proxy: ScrollViewProxy) in
List{
ForEach(items, id: \.self) { Text("\($0)")
}.onDelete(perform: removeRows)
}.onChange(of: previousIndex) { (e: Equatable) in
proxy.scrollTo(previousIndex!-4, anchor: .top)
//proxy.scrollTo(0, anchor: .top) // will display 1st cell
}
}
}
}
This can now be simplified with all new ScrollViewProxy in Xcode 12, like so:
struct ContentView: View {
let itemCount: Int = 100
var body: some View {
ScrollViewReader { value in
VStack {
Button("Scroll to top") {
value.scrollTo(0)
}
Button("Scroll to buttom") {
value.scrollTo(itemCount-1)
}
ScrollView {
LazyVStack {
ForEach(0 ..< itemCount) { i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
}
}
}
}
}
}
}
MacOS 11: In case you need to scroll a list based on input outside the view hierarchy. I have followed the original scroll proxy pattern using the new scrollViewReader:
struct ScrollingHelperInjection: NSViewRepresentable {
let proxy: ScrollViewProxy
let helper: ScrollingHelper
func makeNSView(context: Context) -> NSView {
return NSView()
}
func updateNSView(_ nsView: NSView, context: Context) {
helper.catchProxy(for: proxy)
}
}
final class ScrollingHelper {
//updated for mac os v11
private var proxy: ScrollViewProxy?
func catchProxy(for proxy: ScrollViewProxy) {
self.proxy = proxy
}
func scrollTo(_ point: Int) {
if let scroller = proxy {
withAnimation() {
scroller.scrollTo(point)
}
} else {
//problem
}
}
}
Environmental object:
#Published var scrollingHelper = ScrollingHelper()
In the view: ScrollViewReader { reader in .....
Injection in the view:
.background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)
Usage outside the view hierarchy: scrollingHelper.scrollTo(3)
As mentioned in #lachezar-todorov's answer Introspect is a nice library to access UIKit elements in SwiftUI. But be aware that the block you use for accessing UIKit elements are being called multiple times. This can really mess up your app state. In my cas CPU usage was going %100 and app was getting unresponsive. I had to use some pre conditions to avoid it.
ScrollView() {
...
}.introspectScrollView { scrollView in
if aPreCondition {
//Your scrolling logic
}
}
Another cool way is to just use namespace wrappers:
A dynamic property type that allows access to a namespace defined by the persistent identity of the object containing the property (e.g. a view).
struct ContentView: View {
#Namespace private var topID
#Namespace private var bottomID
let items = (0..<100).map { $0 }
var body: some View {
ScrollView {
ScrollViewReader { proxy in
Section {
LazyVStack {
ForEach(items.indices, id: \.self) { index in
Text("Item \(items[index])")
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.green.cornerRadius(16))
}
}
} header: {
HStack {
Text("header")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(bottomID)
}
}
) {
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(topID)
} footer: {
HStack {
Text("Footer")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(topID) }
}
) {
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(bottomID)
}
.padding()
}
}
.foregroundColor(.white)
.background(.black)
}
}
Two parts:
Wrap the List (or ScrollView) with ScrollViewReader
Use the scrollViewProxy (that comes from ScrollViewReader) to scroll to an id of an element in the List. You can seemingly use EmptyView().
The example below uses a notification for simplicity (use a function if you can instead!).
ScrollViewReader { scrollViewProxy in
List {
EmptyView().id("top")
}
.onReceive(NotificationCenter.default.publisher(for: .ScrollToTop)) { _ in
// when using an anchor of `.top`, it failed to go all the way to the top
// so here we add an extra -50 so it goes to the top
scrollViewProxy.scrollTo("top", anchor: UnitPoint(x: 0, y: -50))
}
}
extension Notification.Name {
static let ScrollToTop = Notification.Name("ScrollToTop")
}
NotificationCenter.default.post(name: .ScrollToTop, object: nil)

Resources