List Item with Picker (segmented control) subview and selection gesture - ios

I have a List and items containing a Picker view (segmented control stye). I would like to handle selection and picker state separately. the picker should be disabled when list item is not selected.
The problem is:
first tap - selects the list item. (selected = true)
tap on picker - set selection = false
On UIKit, Button/SegmentControl/etc... catch the 'tap' and do not pass through to TableView selection state.
struct ListView: View {
#State var selected = Set<Int>()
let items = (1...10).map { ItemDataModel(id: $0) }
var body: some View {
List(items) { item in
ListItemView(dataModel: item)
.onTapGesture { if !(selected.remove(item.id) != .none) { selected.insert(item.id) }}
}
}
}
struct ListItemView: View {
#ObservedObject var dataModel: ItemDataModel
var body: some View {
let pickerBinding = Binding<Int>(
get: { dataModel.state?.rawValue ?? -1 },
set: { dataModel.state = DataState(rawValue: $0) }
)
HStack {
Text(dataModel.title)
Spacer()
Picker("sdf", selection: pickerBinding) {
Text("State 1").tag(0)
Text("State 2").tag(1)
Text("State 3").tag(2)
}.pickerStyle(SegmentedPickerStyle())
}
}
}
enum DataState: Int {
case state1, state2, state3
}
class ItemDataModel: Hashable, Identifiable, ObservableObject {
let id: Int
let title: String
#Published var state: DataState? = .state1
init(id: Int) {
self.id = id
title = "item \(id)"
}
func hash(into hasher: inout Hasher) {
hasher.combine(title)
}
static func == (lhs: ItemDataModel, rhs: ItemDataModel) -> Bool {
return lhs.id == rhs.id
}
}
[Please try to ignore syntax errors (if exsist) and focus on the gesture issue]

A possible solution is to apply modifiers only when the Picker is not selected:
struct ListView: View {
#State var selected = Set<Int>()
let items = (1 ... 10).map { ItemDataModel(id: $0) }
var body: some View {
List(items) { item in
if selected.contains(item.id) {
ListItemView(dataModel: item)
} else {
ListItemView(dataModel: item)
.disabled(true)
.onTapGesture {
if !(selected.remove(item.id) != .none) {
selected.insert(item.id)
}
}
}
}
}
}

Related

Toggle Required Multiple Taps to Work in SwiftUI

I have a List in SwiftUI, which displays books. Each book has a isFinished property. When the user taps on the book, it takes them to the detail page where they can toggle the isFinished status. The problem, I am facing is that on the BookDetailView, when the user taps on the Toggle it does not change the toggle status on the first tap. I can tap twice to change the Toggle from off to on etc.
Here is my complete code:
struct Book: Hashable, Identifiable {
let id = UUID()
let name: String
var isFinished: Bool = true
}
struct BookCellView: View {
let book: Book
var body: some View {
HStack {
Text(book.name)
Text(book.isFinished ? "Finished": "Not finished")
}
}
}
struct BookDetailView: View {
#Binding var book: Book
#State private var isOn: Bool = false
var body: some View {
VStack {
Text(book.name)
Toggle(isOn: $book.isFinished) {
Text("Finished")
}.fixedSize()
}
}
}
struct ContentView: View {
#State private var books = [Book(name: "Harry Potter"), Book(name: "Peek Performance"), Book(name: "Ready Player One")]
func bookBinding(id: UUID) -> Binding<Book> {
let index = books.firstIndex(where: { $0.id == id })!
return Binding {
return books[index]
} set: {
books[index] = $0
}
}
var body: some View {
NavigationStack {
List(books) { book in
NavigationLink(value: book) {
BookCellView(book: book)
}
}.navigationDestination(for: Book.self) { book in
BookDetailView(book: bookBinding(id: book.id))
}
}
}
}
Any ideas what I am missing?
UPDATE 1:
struct ContentView: View {
#State private var books = [Book(name: "Harry Potter"), Book(name: "Peek Performance"), Book(name: "Ready Player One")]
/*
func bookBinding(id: UUID) -> Binding<Book> {
let index = books.firstIndex(where: { $0.id == id })!
return Binding {
return books[index]
} set: {
books[index] = $0
}
} */
var body: some View {
NavigationStack {
List(books) { book in
NavigationLink(value: book.id) {
BookCellView(book: book)
}
}.navigationDestination(for: UUID.self) { id in
let index = books.firstIndex(where: { $0.id == id })!
BookDetailView(book: $books[index])
}
}
}
}

'SwiftUI' how to change checkmark in `listview`

I'm trying to change the checkmark image on listview row selection, but I'm not getting expected result from selection.
Below code I'm creating listview from the array todo items and added tapGesture to the row. When I'm selecting row I can able to change the completed value but not reflecting that on UI.
struct ContentView: View {
#State var items: [Todo] = todoItems
var body: some View {
List(items, id: \.self) { item in
Row(item: item) { item in
item.completed.toggle()
print(item.completed)
}
}
}
}
struct Row: View {
let item: Todo
var onTap: ((_ item: Todo) -> Void)?
var body: some View {
HStack {
Image(systemName: item.completed ? "checkmark.square.fill" : "squareshape" )
.foregroundColor(item.completed ? .green : .gray)
Text(item.title).font(.headline).foregroundColor(.secondary)
}.onTapGesture {
onTap?(item)
}
}
}
class Todo: Hashable {
static func == (lhs: Todo, rhs: Todo) -> Bool {
lhs.title == rhs.title
}
var title: String
var completed: Bool
init(title: String, completed: Bool) {
self.title = title
self.completed = completed
}
func hash(into hasher: inout Hasher) {
hasher.combine(title)
}
}
let todoItems = [
Todo(title: "$300", completed: false),
Todo(title: "$550", completed: false),
Todo(title: "$450", completed: false)
]
You can make Todo a struct. For data like this, it is much more preferable for it to be a struct rather than a class.
The item also doesn't need to be passed down, since you already get it in the List. I also used the $ syntax within for $items, so $item is a Binding and you therefore can mutate item. Changing item is changing that element in #State variable items, therefore a view update occurs.
Your implementation for Equatable and Hashable on Todo was incorrect - because otherwise the view won't update if a Todo instance is the same, even with a different completed value. I added Identifiable to Todo anyway, so you can implicitly identify by id.
Code:
struct ContentView: View {
#State var items: [Todo] = todoItems
var body: some View {
List($items) { $item in
Row(item: item) {
item.completed.toggle()
}
}
}
}
struct Row: View {
let item: Todo
let onTap: (() -> Void)?
var body: some View {
HStack {
Image(systemName: item.completed ? "checkmark.square.fill" : "squareshape" )
.foregroundColor(item.completed ? .green : .gray)
Text(item.title).font(.headline).foregroundColor(.secondary)
}.onTapGesture {
onTap?()
}
}
}
struct Todo: Hashable, Identifiable {
let id = UUID()
var title: String
var completed: Bool
init(title: String, completed: Bool) {
self.title = title
self.completed = completed
}
}

Observable object model not changing View values when model is updated

I'm losing my mind over this, please help
I'm following the standford's iOS tutorial, I'm trying to finish an assignment of creating a card games, I have 3 models, Game, Card, Theme and Themes:
Game and Card are in charge of the main game logic
import Foundation
struct Game {
var cards: [Card]
var score = 0
var isGameOver = false
var theme: Theme
var choosenCardIndex: Int?
init(theme: Theme) {
cards = []
self.theme = theme
startTheme()
}
mutating func startTheme() {
cards = []
var contentItems: [String] = []
while contentItems.count != theme.numberOfPairs {
let randomElement = theme.emojis.randomElement()!
if !contentItems.contains(randomElement) {
contentItems.append(randomElement)
}
}
let secondContentItems: [String] = contentItems.shuffled()
for index in 0..<theme.numberOfPairs {
cards.append(Card(id: index*2, content: contentItems[index]))
cards.append(Card(id: index*2+1, content: secondContentItems[index]))
}
}
mutating func chooseCard(_ card: Card) {
print(card)
if let foundIndex = cards.firstIndex(where: {$0.id == card.id}),
!cards[foundIndex].isFaceUp,
!cards[foundIndex].isMatchedUp
{
if let potentialMatchIndex = choosenCardIndex {
if cards[foundIndex].content == cards[potentialMatchIndex].content {
cards[foundIndex].isMatchedUp = true
cards[potentialMatchIndex].isMatchedUp = true
}
choosenCardIndex = nil
} else {
for index in cards.indices {
cards[index].isFaceUp = false
}
}
cards[foundIndex].isFaceUp.toggle()
}
print(card)
}
mutating func endGame() {
isGameOver = true
}
mutating func penalizePoints() {
score -= 1
}
mutating func awardPoints () {
score += 2
}
struct Card: Identifiable, Equatable {
static func == (lhs: Game.Card, rhs: Game.Card) -> Bool {
return lhs.content == rhs.content
}
var id: Int
var isFaceUp: Bool = false
var content: String
var isMatchedUp: Bool = false
var isPreviouslySeen = false
}
}
Theme is for modeling different kind of content, Themes is for keeping track which one is currently in use and for fetching a new one
import Foundation
import SwiftUI
struct Theme: Equatable {
static func == (lhs: Theme, rhs: Theme) -> Bool {
return lhs.name == rhs.name
}
internal init(name: String, emojis: [String], numberOfPairs: Int, cardsColor: Color) {
self.name = name
self.emojis = Array(Set(emojis))
if(numberOfPairs > emojis.count || numberOfPairs < 1) {
self.numberOfPairs = emojis.count
} else {
self.numberOfPairs = numberOfPairs
}
self.cardsColor = cardsColor
}
var name: String
var emojis: [String]
var numberOfPairs: Int
var cardsColor: Color
}
import Foundation
struct Themes {
private let themes: [Theme]
public var currentTheme: Theme?
init(_ themes: [Theme]) {
self.themes = themes
self.currentTheme = getNewTheme()
}
private func getNewTheme() -> Theme {
let themesIndexes: [Int] = Array(0..<themes.count)
var visitedIndexes: [Int] = []
while(visitedIndexes.count < themesIndexes.count) {
let randomIndex = Int.random(in: 0..<themes.count)
let newTheme = themes[randomIndex]
if newTheme == currentTheme {
visitedIndexes.append(randomIndex)
} else {
return newTheme
}
}
return themes.randomElement()!
}
mutating func changeCurrentTheme() -> Theme {
self.currentTheme = getNewTheme()
return self.currentTheme!
}
}
This is my VM:
class GameViewModel: ObservableObject {
static let numbersTheme = Theme(name: "WeirdNumbers", emojis: ["1", "2", "4", "9", "20", "30"], numberOfPairs: 6, cardsColor: .pink)
static let emojisTheme = Theme(name: "Faces", emojis: ["ðŸĨ°", "😄", "😜", "ðŸĨģ", "ðŸĪ“", "😎", "😋", "ðŸĪĐ"], numberOfPairs: 8, cardsColor: .blue)
static let carsTheme = Theme(name: "Cars", emojis: ["🚓", "🏎ïļ", "🚗", "🚎", "🚒", "🚙", "🚑", "🚌"], numberOfPairs: 20, cardsColor: .yellow)
static let activitiesTheme = Theme(name: "Activities", emojis: ["ðŸĪš", "🏌ïļ", "🏄‍♂ïļ", "ðŸšĢ", "🏊‍♂ïļ", "🏋ïļ", "ðŸšī‍♂ïļ"], numberOfPairs: -10, cardsColor: .green)
static let fruitsTheme = Theme(name: "Fruits", emojis: ["🍇", "🍉", "🍈", "🍊", "🍋", "🍎", "🍏", "ðŸĨ­"], numberOfPairs: 5, cardsColor: .purple)
static var themes = Themes([numbersTheme, emojisTheme, carsTheme, fruitsTheme])
static func createMemoryGame() -> Game {
Game(theme: themes.currentTheme!)
}
#Published private var gameController: Game = Game(theme: themes.currentTheme!)
func createNewGame() {
gameController.theme = GameViewModel.themes.changeCurrentTheme()
gameController.startTheme()
}
func choose(_ card: Game.Card) {
objectWillChange.send()
gameController.chooseCard(card)
}
var cards: [Game.Card] {
return gameController.cards
}
var title: String {
return gameController.theme.name
}
var color: Color {
return gameController.theme.cardsColor
}
}
And this is my view:
struct ContentView: View {
var columns: [GridItem] = [GridItem(.adaptive(minimum: 90, maximum: 400))]
#ObservedObject var ViewModel: GameViewModel
var body: some View {
VStack {
HStack {
Spacer()
Button(action: {
ViewModel.createNewGame()
}, label: {
VStack {
Image(systemName: "plus")
Text("New game")
.font(/*#START_MENU_TOKEN#*/.caption/*#END_MENU_TOKEN#*/)
}
})
.font(/*#START_MENU_TOKEN#*/.title/*#END_MENU_TOKEN#*/)
.padding(.trailing)
}
Section {
VStack {
Text(ViewModel.title)
.foregroundColor(/*#START_MENU_TOKEN#*/.blue/*#END_MENU_TOKEN#*/)
.font(/*#START_MENU_TOKEN#*/.title/*#END_MENU_TOKEN#*/)
}
}
ScrollView {
LazyVGrid(columns: columns ) {
ForEach(ViewModel.cards, id: \.id) { card in
Card(card: card, color: ViewModel.color)
.aspectRatio(2/3, contentMode: .fit)
.onTapGesture {
ViewModel.choose(card)
}
}
}
.font(.largeTitle)
}
.padding()
Text("Score")
.frame(maxWidth: .infinity, minHeight: 30)
.background(Color.blue)
.foregroundColor(/*#START_MENU_TOKEN#*/.white/*#END_MENU_TOKEN#*/)
Spacer()
HStack {
Spacer()
Text("0")
.font(.title2)
.bold()
Spacer()
}
}
}
}
struct Card: View {
let card: Game.Card
let color: Color
var body: some View {
ZStack {
let shape = RoundedRectangle(cornerRadius: 10)
if card.isFaceUp {
Text(card.content)
shape
.strokeBorder()
.accentColor(color)
.foregroundColor(color)
}
else {
shape
.fill(color)
}
}
}
}
Basically the problem lies with the
.onTapGesture {
ViewModel.choose(card)
}
Of the View, when someone taps a card, the isFaceUp property of the Card is changed to true, but this doesn't get reflected in the UI.
If I generate a new view by changing the theme and adding new cards, this works.
Button(action: {
ViewModel.createNewGame()
}, label: {
VStack {
Image(systemName: "plus")
Text("New game")
.font(/*#START_MENU_TOKEN#*/.caption/*#END_MENU_TOKEN#*/)
}
})
But when I'm trying to flip a card it doesn't work, the value changes in the Game model but it's not updated on the view
After the tap the ViewModel calls the choose method
func choose(_ card: Game.Card) {
gameController.chooseCard(card)
}
And this changed the value of the Model in the Game.swift file by calling the chooseCard method
mutating func chooseCard(_ card: Card) {
print(card)
if let foundIndex = cards.firstIndex(where: {$0.id == card.id}),
!cards[foundIndex].isFaceUp,
!cards[foundIndex].isMatchedUp
{
if let potentialMatchIndex = choosenCardIndex {
if cards[foundIndex].content == cards[potentialMatchIndex].content {
cards[foundIndex].isMatchedUp = true
cards[potentialMatchIndex].isMatchedUp = true
}
choosenCardIndex = nil
} else {
for index in cards.indices {
cards[index].isFaceUp = false
}
}
cards[foundIndex].isFaceUp.toggle()
}
print(card)
}
The values changes but the view does not, the gameController variable of the GameViewModel has the #Published state, which points to an instance of the Game model struct
#Published private var gameController: Game = Game(theme: themes.currentTheme!)
And the view it's accesing this GameViewModel with the #ObservedObject property
#ObservedObject var ViewModel: GameViewModel
I thought I was doing everything right, but I guess not lol, what the heck am I doing wrong? Why can't update my view if I'm using published and observable object on my ViewModel? lol
The main reason the card view doesn't see changes is because in your card view you did put an equatable conformance protocol where you specify an equality check == function that just checks for content and not other variable changes
static func ==(lhs: Game.Card, rhs: Game.Card) -> Bool {
lhs.content == rhs.content
// && lhs.isFaceUp && rhs.isFaceUp //<- you can still add this
}
if you remove the equatable protocol and leave swift to check for equality it should be the minimal change from your base solution.
I would still use the solution where you change the state of the class card so the view can react to changes as an ObservableObject, and the #Published for changes that the view need to track, like this:
class Card: Identifiable, Equatable, ObservableObject {
var id: Int
#Published var isFaceUp: Bool = false
var content: String
#Published var isMatchedUp: Bool = false
var isPreviouslySeen = false
internal init(id: Int, content: String) {
self.id = id
self.content = content
}
static func ==(lhs: Game.Card, rhs: Game.Card) -> Bool {
lhs.content == rhs.content
}
}
and in the Card view the card variable will become
struct Card: View {
#ObservedObject var card: Game.Card
...
}
btw you don't need to notify the view of changes with
objectWillChange.send() if you are already using the #Published notation. every set to the variable will trigger an update.
you could try this instead of declaring Card a class:
Card(card: card, color: ViewModel.color, isFaceUp: card.isFaceUp)
and add this to the Card view:
let isFaceUp: Bool
My understanding is that the Card view does not see any changes to the card (not sure why, maybe because it is in an if),
but if you give it something that has really changed then it is re-rendered. And as mentioned before no need for objectWillChange.send()
EDIT1:
you could also do this in "ContentView":
Card(viewModel: ViewModel, card: card)
and then
struct Card: View {
#ObservedObject var viewModel: GameViewModel
let card: Game.Card
var body: some View {
ZStack {
let shape = RoundedRectangle(cornerRadius: 10)
if card.isFaceUp {
Text(card.content)
shape
.strokeBorder()
.accentColor(viewModel.color)
.foregroundColor(viewModel.color)
}
else {
shape.fill(viewModel.color)
}
}
}
}

How to reset child view state variable with SwiftUI?

I'm sure it's something very silly but how should one reset the state value of a child view when another state has changed?
For example, the code below shows 2 folders, which respectively have 2 and 3 items., which can be edited.
If you select the second folder (Work) and its 3rd item (Peter) and then select the first folder (Home), the app crashes since selectedItemIndex is out of bounds.
I tried to "reset" the state value when the view gets initialized but it seems like changing the state like such triggers out a "runtime: SwiftUI: Modifying state during view update, this will cause undefined behavior." warning.
init(items: Binding<[Item]>) {
self._items = items
self._selectedItemIndex = State(wrappedValue: 0)
}
What is the proper way to do this? Thanks!
Here's the code:
AppDelegate.swift
import Cocoa
import SwiftUI
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let store = ItemStore()
let contentView = ContentView(store: store)
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
}
ContentView.swift
import SwiftUI
final class ItemStore: ObservableObject {
#Published var data: [Folder] = [Folder(name: "Home",
items: [Item(name: "Mark"), Item(name: "Vincent")]),
Folder(name: "Work",
items:[Item(name: "Joseph"), Item(name: "Phil"), Item(name: "Peter")])]
}
struct Folder: Identifiable {
var id = UUID()
var name: String
var items: [Item]
}
struct Item: Identifiable {
static func == (lhs: Item, rhs: Item) -> Bool {
return true
}
var id = UUID()
var name: String
var content = Date().description
init(name: String) {
self.name = name
}
}
struct ContentView: View {
#ObservedObject var store: ItemStore
#State var selectedFolderIndex: Int?
var body: some View {
HSplitView {
// FOLDERS
List(selection: $selectedFolderIndex) {
Section(header: Text("Groups")) {
ForEach(store.data.indexed(), id: \.1.id) { index, folder in
Text(folder.name).tag(index)
}
}.collapsible(false)
}
.listStyle(SidebarListStyle())
// ITEMS
if selectedFolderIndex != nil {
ItemsView(items: $store.data[selectedFolderIndex!].items)
}
}
.frame(minWidth: 800, maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ItemsView: View {
#Binding var items: [Item]
#State var selectedItemIndex: Int?
var body: some View {
HSplitView {
List(selection: $selectedItemIndex) {
ForEach(items.indexed(), id: \.1.id) { index, item in
Text(item.name).tag(index)
}
}
.frame(width: 300)
if selectedItemIndex != nil {
DetailView(item: $items[selectedItemIndex!])
.padding()
.frame(minWidth: 200, maxHeight: .infinity)
}
}
}
init(items: Binding<[Item]>) {
self._items = items
self._selectedItemIndex = State(wrappedValue: 0)
}
}
struct DetailView: View {
#Binding var item: Item
var body: some View {
VStack {
TextField("", text: $item.name)
}
}
}
// Credit: https://swiftwithmajid.com/2019/07/03/managing-data-flow-in-swiftui/
struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
typealias Index = Base.Index
typealias Element = (index: Index, element: Base.Element)
let base: Base
var startIndex: Index { base.startIndex }
var endIndex: Index { base.endIndex }
func index(after i: Index) -> Index {
base.index(after: i)
}
func index(before i: Index) -> Index {
base.index(before: i)
}
func index(_ i: Index, offsetBy distance: Int) -> Index {
base.index(i, offsetBy: distance)
}
subscript(position: Index) -> Element {
(index: position, element: base[position])
}
}
extension RandomAccessCollection {
func indexed() -> IndexedCollection<Self> {
IndexedCollection(base: self)
}
}
Thanks to #jordanpittman for suggesting a fix:
ItemsView(items: $store.data[selectedFolderIndex!].items).id(selectedRowIndex)
Source: https://swiftui-lab.com/swiftui-id
Fully playable sample draft for ContentView.swift. Play with it in both edit modes (inactive/active row selection) and adopt to your needs.
import SwiftUI
struct ItemStore {
var data: [Folder] = [Folder(name: "Home", items: [Item(name: "Mark"), Item(name: "Vincent")]),
Folder(name: "Work", items:[Item(name: "Joseph"), Item(name: "Phil"), Item(name: "Peter")])]
}
struct Folder: Identifiable {
var id = UUID()
var name: String
var items: [Item]
}
struct Item: Identifiable {
var id = UUID()
var name: String
var content = Date().description
}
struct ContentView: View {
#State var store: ItemStore
#State var selectedFolderIndex: Int? = 0
#State private var editMode = EditMode.inactive
var body: some View {
NavigationView {
VStack {
// FOLDERS
List(selection: $selectedFolderIndex) {
Section(header: Text("Groups")) {
ForEach(store.data.indexed(), id: \.1.id) { index, folder in
HStack {
Text(folder.name).tag(index)
Spacer()
}
.background(Color.white) //make the whole row tapable, not just the text
.frame(maxWidth: .infinity)
.multilineTextAlignment(.leading)
.onTapGesture {
self.selectedFolderIndex = index
}
}.onDelete(perform: delete)
}
}
.listStyle(GroupedListStyle())
.id(selectedFolderIndex)
// ITEMS
if selectedFolderIndex != nil && (($store.data.wrappedValue.startIndex..<$store.data.wrappedValue.endIndex).contains(selectedFolderIndex!) ){
ItemsView(items: $store.data[selectedFolderIndex!].items)
}
}
.navigationBarTitle("Title")
.navigationBarItems(trailing: EditButton())
.environment(\.editMode, $editMode)
}
}
func delete(at offsets: IndexSet) {
$store.wrappedValue.data.remove(atOffsets: offsets) // Note projected value! `store.data.remove() will not modify SwiftUI on changes and it will crash because of invalid index.
}
}
struct ItemsView: View {
#Binding var items: [Item]
#State var selectedDetailIndex: Int?
var body: some View {
HStack {
List(selection: $selectedDetailIndex) {
ForEach(items.indexed(), id: \.1.id) { index, item in
Text(item.name).tag(index)
.onTapGesture {
self.selectedDetailIndex = index
}
}
}
if selectedDetailIndex != nil && (($items.wrappedValue.startIndex..<$items.wrappedValue.endIndex).contains(selectedDetailIndex!) ) {
DetailView(item: $items[selectedDetailIndex!])
.padding()
}
}
}
}
struct DetailView: View {
#Binding var item: Item
var body: some View {
VStack {
TextField("", text: $item.name)
}
}
}
// Credit: https://swiftwithmajid.com/2019/07/03/managing-data-flow-in-swiftui/
struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
typealias Index = Base.Index
typealias Element = (index: Index, element: Base.Element)
let base: Base
var startIndex: Index { base.startIndex }
var endIndex: Index { base.endIndex }
func index(after i: Index) -> Index {
base.index(after: i)
}
func index(before i: Index) -> Index {
base.index(before: i)
}
func index(_ i: Index, offsetBy distance: Int) -> Index {
base.index(i, offsetBy: distance)
}
subscript(position: Index) -> Element {
(index: position, element: base[position])
}
}
extension RandomAccessCollection {
func indexed() -> IndexedCollection<Self> {
IndexedCollection(base: self)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(store: ItemStore())
}
}

SwiftUI pagination for List object

I've implemented a List with a search bar in SwiftUI. Now I want to implement paging for this list. When the user scrolls to the bottom of the list, new elements should be loaded. My problem is, how can I detect that the user scrolled to the end? When this happens I want to load new elements, append them and show them to the user.
My code looks like this:
import Foundation
import SwiftUI
struct MyList: View {
#EnvironmentObject var webService: GetRequestsWebService
#ObservedObject var viewModelMyList: MyListViewModel
#State private var query = ""
var body: some View {
let binding = Binding<String>(
get: { self.query },
set: { self.query = $0; self.textFieldChanged($0) }
)
return NavigationView {
// how to detect here when end of the list is reached by scrolling?
List {
// searchbar here inside the list element
TextField("Search...", text: binding) {
self.fetchResults()
}
ForEach(viewModelMyList.items, id: \.id) { item in
MyRow(itemToProcess: item)
}
}
.navigationBarTitle("Title")
}.onAppear(perform: fetchResults)
}
private func textFieldChanged(_ text: String) {
text.isEmpty ? viewModelMyList.fetchResultsThrottelt(for: nil) : viewModelMyList.fetchResultsThrottelt(for: text)
}
private func fetchResults() {
query.isEmpty ? viewModelMyList.fetchResults(for: nil) : viewModelMyList.fetchResults(for: query)
}
}
Also a little bit special this case, because the list contains the search bar. I would be thankful for any advice because with this :).
As you have already a List with an artificial row for the search bar, you can simply add another view to the list which will trigger another fetch when it appears on screen (using onAppear() as suggested by Josh). By doing this you do not have to do any "complicated" calculations to know whether a row is the last row... the artificial row is always the last one!
I already used this in one of my projects and I've never seen this element on the screen, as the loading was triggered so quickly before it appeared on the screen. (You surely can use a transparent/invisible element, or perhaps even use a spinner ;-))
List {
TextField("Search...", text: binding) {
/* ... */
}
ForEach(viewModelMyList.items, id: \.id) { item in
// ...
}
if self.viewModelMyList.hasMoreRows {
Text("Fetching more...")
.onAppear(perform: {
self.viewModelMyList.fetchMore()
})
}
}
Add a .onAppear() to the MyRow and have it call the viewModel with the item that just appears. You can then check if its equal to the last item in the list or if its n items away from the end of the list and trigger your pagination.
This one worked for me:
You can add pagination with two different approaches to your List: Last item approach and Threshold item approach.
That's way this package adds two functions to RandomAccessCollection:
isLastItem
Use this function to check if the item in the current List item iteration is the last item of your collection.
isThresholdItem
With this function you can find out if the item of the current List item iteration is the item at your defined threshold. Pass an offset (distance to the last item) to the function so the threshold item can be determined.
import SwiftUI
extension RandomAccessCollection where Self.Element: Identifiable {
public func isLastItem<Item: Identifiable>(_ item: Item) -> Bool {
guard !isEmpty else {
return false
}
guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
return false
}
let distance = self.distance(from: itemIndex, to: endIndex)
return distance == 1
}
public func isThresholdItem<Item: Identifiable>(
offset: Int,
item: Item
) -> Bool {
guard !isEmpty else {
return false
}
guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
return false
}
let distance = self.distance(from: itemIndex, to: endIndex)
let offset = offset < count ? offset : count - 1
return offset == (distance - 1)
}
}
Examples
Last item approach:
struct ListPaginationExampleView: View {
#State private var items: [String] = Array(0...24).map { "Item \($0)" }
#State private var isLoading: Bool = false
#State private var page: Int = 0
private let pageSize: Int = 25
var body: some View {
NavigationView {
List(items) { item in
VStack(alignment: .leading) {
Text(item)
if self.isLoading && self.items.isLastItem(item) {
Divider()
Text("Loading ...")
.padding(.vertical)
}
}.onAppear {
self.listItemAppears(item)
}
}
.navigationBarTitle("List of items")
.navigationBarItems(trailing: Text("Page index: \(page)"))
}
}
}
extension ListPaginationExampleView {
private func listItemAppears<Item: Identifiable>(_ item: Item) {
if items.isLastItem(item) {
isLoading = true
/*
Simulated async behaviour:
Creates items for the next page and
appends them to the list after a short delay
*/
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) {
self.page += 1
let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
self.items.append(contentsOf: moreItems)
self.isLoading = false
}
}
}
}
Threshold item approach:
struct ListPaginationThresholdExampleView: View {
#State private var items: [String] = Array(0...24).map { "Item \($0)" }
#State private var isLoading: Bool = false
#State private var page: Int = 0
private let pageSize: Int = 25
private let offset: Int = 10
var body: some View {
NavigationView {
List(items) { item in
VStack(alignment: .leading) {
Text(item)
if self.isLoading && self.items.isLastItem(item) {
Divider()
Text("Loading ...")
.padding(.vertical)
}
}.onAppear {
self.listItemAppears(item)
}
}
.navigationBarTitle("List of items")
.navigationBarItems(trailing: Text("Page index: \(page)"))
}
}
}
extension ListPaginationThresholdExampleView {
private func listItemAppears<Item: Identifiable>(_ item: Item) {
if items.isThresholdItem(offset: offset,
item: item) {
isLoading = true
/*
Simulated async behaviour:
Creates items for the next page and
appends them to the list after a short delay
*/
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
self.page += 1
let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
self.items.append(contentsOf: moreItems)
self.isLoading = false
}
}
}
}
String Extension:
/*
If you want to display an array of strings
in the List view you have to specify a key path,
so each string can be uniquely identified.
With this extension you don't have to do that anymore.
*/
extension String: Identifiable {
public var id: String {
return self
}
}
Christian Elies, code reference

Resources