How to detect that LazyVGrid's items get re-built? - swiftui

In my app, LazyVGrid re-builds its contents multiple times. The number of items in the grid may vary or remain the same. Each time a particular item must be scrolled into view programmatically.
When the LazyVGrid first appears, an item can be scrolled into view using the onAppear() modifier.
Is there any way of detecting the moment when the LazyVGrid finishes re-building its items next time so that the grid can be safely scrolled?
Here is my code:
Grid
struct Grid: View {
#ObservedObject var viewModel: ViewModel
var columns: [GridItem] {
Array(repeating: .init(.flexible(), alignment: .topLeading), count: viewModel.data.count / viewModel.rows)
}
var body: some View {
GeometryReader { geometry in
ScrollView {
ScrollViewReader { scrollViewProxy in
LazyVGrid(columns: columns) {
let rowsCount = viewModel.rows
let columsCount = columns.count
ForEach((0..<rowsCount*columsCount), id: \.self) { index in
let data = viewModel.getData(for: index)
Text(data)
.id(index)
}
}
.onAppear {
// Scroll a particular item into view
let targetIndex = 32 // an arbitrary number for simplicity sake
scrollViewProxy.scrollTo(targetIndex, anchor: .top)
}
.onChange(of: geometry.size.width) { newWidth in
// Available screen width changed, for example on device rotation
// We need to re-build the grid to show more or less columns respectively.
// To achive this, we re-load data
// Problem: how to detect the moment when the LazyVGrid
// finishes re-building its items
// so that the grid can be safely scrolled?
let availableWidth = geometry.size.width
let columnsNumber = ScreenWidth.getNumberOfColumns(width: Int(availableWidth))
Task {
await viewModel.loadData(columnsNumber)
}
}
}
}
}
}
}
Helper enum to determine the number of columns to show in the grid
enum ScreenWidth: Int, CaseIterable {
case extraSmall = 320
case small = 428
case middle = 568
case large = 667
case extraLarge = 1080
static func getNumberOfColumns(width: Int) -> Int {
var screenWidth: ScreenWidth = .extraSmall
for w in ScreenWidth.allCases {
if width >= w.rawValue {
screenWidth = w
}
}
var numberOfColums: Int
switch screenWidth {
case .extraSmall:
numberOfColums = 2
case .small:
numberOfColums = 3
case .middle:
numberOfColums = 4
case .large:
numberOfColums = 5
case .extraLarge:
numberOfColums = 8
}
return numberOfColums
}
}
Simplified view model
final class ViewModel: ObservableObject {
#Published private(set) var data: [String] = []
var rows: Int = 26
init() {
data = loadDataHelper(3)
}
func loadData(_ cols: Int) async {
// emulating data loading latency
await Task.sleep(UInt64(1 * Double(NSEC_PER_SEC)))
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.data = _self.loadDataHelper(cols)
}
}
}
private func loadDataHelper(_ cols: Int) -> [String] {
var dataGrid : [String] = []
for index in 0..<rows*cols {
dataGrid.append("\(index) Lorem ipsum dolor sit amet")
}
return dataGrid
}
func getData(for index: Int) -> String {
if (index > data.count-1){
return "No data"
}
return data[index]
}
}

I found two solutions.
The first one is to put LazyVGrid inside ForEach with its range’s upper bound equal to an Int published variable incremented each time data is updated. In this way a new instance of LazyVGrid is created on each update so we can make use of LazyVGrid’s onAppear method to do some initialization work, in this case scroll a particular item into view.
Here is how it can be implemented:
struct Grid: View {
#ObservedObject var viewModel: ViewModel
var columns: [GridItem] {
Array(repeating: .init(.flexible(), alignment: .topLeading), count: viewModel.data.count / viewModel.rows)
}
var body: some View {
GeometryReader { geometry in
ScrollView {
ScrollViewReader { scrollViewProxy in
ForEach((viewModel.dataIndex-1..<viewModel.dataIndex), id: \.self) { dataIndex in
LazyVGrid(columns: columns) {
let rowsCount = viewModel.rows
let columsCount = columns.count
ForEach((0..<rowsCount*columsCount), id: \.self) { index in
let data = viewModel.getData(for: index)
Text(data)
.id(index)
}
}
.id(1000 + dataIndex)
.onAppear {
print("LazyVGrid, onAppear, #\(dataIndex)")
let targetItem = 32 // arbitrary number
withAnimation(.linear(duration: 0.3)) {
scrollViewProxy.scrollTo(targetItem, anchor: .top)
}
}
}
}
}
.padding(EdgeInsets(top: 20, leading: 0, bottom: 0, trailing: 0))
.onAppear {
load(availableWidth: geometry.size.width)
}
.onChange(of: geometry.size.width) { newWidth in
// Available screen width changed.
// We need to re-build the grid to show more or less columns respectively.
// To achive this, we re-load data.
load(availableWidth: geometry.size.width)
}
}
}
private func load(availableWidth: CGFloat){
let columnsNumber = ScreenWidth.getNumberOfColumns(width: Int(availableWidth))
Task {
await viewModel.loadData(columnsNumber)
}
}
}
ViewModel
final class ViewModel: ObservableObject {
/*#Published*/ private(set) var data: [String] = []
#Published private(set) var dataIndex = 0
var rows: Int = 46 // arbitrary number
func loadData(_ cols: Int) async {
let newData = loadDataHelper(cols)
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.data = newData
_self.dataIndex += 1
}
}
}
private func loadDataHelper(_ cols: Int) -> [String] {
var dataGrid : [String] = []
for index in 0..<rows*cols {
dataGrid.append("\(index) Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
}
return dataGrid
}
}
--------------------------------------------------------------
The second approach is based on the solution proposed by #NewDev.
The idea is to track grid items' "rendered" status and fire a callback once they have appeared after the grid re-built its contents in response to viewmodel's data change.
RenderModifier keeps track of grid item's "rendered" status using PreferenceKey to collect data.
The .onAppear() modifier is used to set "rendered" status while the .onDisappear() modifier is used to reset the status.
struct RenderedPreferenceKey: PreferenceKey {
static var defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) {
value = value + nextValue() // sum all those that remain to-be-rendered
}
}
struct RenderModifier: ViewModifier {
#State private var toBeRendered = 1
func body(content: Content) -> some View {
content
.preference(key: RenderedPreferenceKey.self, value: toBeRendered)
.onAppear { toBeRendered = 0 }
.onDisappear { /*reset*/ toBeRendered = 1 }
}
}
Convenience methods on View:
extension View {
func trackRendering() -> some View {
self.modifier(RenderModifier())
}
func onRendered(_ perform: #escaping () -> Void) -> some View {
self.onPreferenceChange(RenderedPreferenceKey.self) { toBeRendered in
// Invoke the callback only when all tracked statuses have been set to 0,
// which happens when all of their .onAppear() modifiers are called
if toBeRendered == 0 { perform() }
}
}
}
Before loading new data the view model clears its current data to make the grid remove its contents. This is necessary for the .onDisappear() modifiers to get called on grid items.
final class ViewModel: ObservableObject {
#Published private(set) var data: [String] = []
var dataLoadedFlag: Bool = false
var rows: Int = 46 // arbitrary number
func loadData(_ cols: Int) async {
// Clear data to make the grid remove its items.
// This is necessary for the .onDisappear() modifier to get called on grid items.
if !data.isEmpty {
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.data = []
}
}
// A short pause is necessary for a grid to have time to remove its items.
// This is crucial for scrolling grid for a specific item.
await Task.sleep(UInt64(0.1 * Double(NSEC_PER_SEC)))
}
let newData = loadDataHelper(cols)
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.dataLoadedFlag = true
_self.data = newData
}
}
}
private func loadDataHelper(_ cols: Int) -> [String] {
var dataGrid : [String] = []
for index in 0..<rows*cols {
dataGrid.append("\(index) Lorem ipsum dolor sit amet")
}
return dataGrid
}
func getData(for index: Int) -> String {
if (index > data.count-1){
return "No data"
}
return data[index]
}
}
An example of usage of the trackRendering() and onRendered() functions:
struct Grid: View {
#ObservedObject var viewModel: ViewModel
var columns: [GridItem] {
Array(repeating: .init(.flexible(), alignment: .topLeading), count: viewModel.data.count / viewModel.rows)
}
var body: some View {
GeometryReader { geometry in
ScrollView {
ScrollViewReader { scrollViewProxy in
LazyVGrid(columns: columns) {
let rowsCount = viewModel.rows
let columsCount = columns.count
ForEach((0..<rowsCount*columsCount), id: \.self) { index in
let data = viewModel.getData(for: index)
Text(data)
.id(index)
// set RenderModifier
.trackRendering()
}
}
.onAppear {
load(availableWidth: geometry.size.width)
}
.onChange(of: geometry.size.width) { newWidth in
// Available screen width changed.
// We need to re-build the grid to show more or less columns respectively.
// To achive this, we re-load data.
load(availableWidth: geometry.size.width)
}
.onRendered {
// do scrolling only if data was loaded,
// that is the grid was re-built
if viewModel.dataLoadedFlag {
/*reset*/ viewModel.dataLoadedFlag = false
let targetItem = 32 // arbitrary number
scrollViewProxy.scrollTo(targetItem, anchor: .top)
}
}
}
}
}
}
private func load(availableWidth: CGFloat){
let columnsNumber = ScreenWidth.getNumberOfColumns(width: Int(availableWidth))
Task {
await viewModel.loadData(columnsNumber)
}
}
}

Related

SwiftUI Preference Key to make uniformly sized boxes

UPDATE: I've made this minimally reproducible.
I wish to make a grid for the alphabet, with each box the same size, looking like this:
I have a PreferenceKey, and a View extension, like this:
struct WidthPreference: PreferenceKey {
static let defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
extension View {
func sizePreference(letterIdx: Int) -> some View {
background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreference.self, value: proxy.size.width)
})
}
}
My primary view is an HStack nestled in a VStack, with each letter as a separate view. Here is the ContentView, and its Alphabet Grid:
struct ContentView: View {
#StateObject var theModel = MyModel()
var body: some View {
AlphabetGrid()
.textCase(.uppercase)
.font(.body)
.onAppear() {
theModel.initializeLetters()
}
.environmentObject(theModel)
}
}
struct AlphabetGrid: View {
#EnvironmentObject var theModel: MyModel
var spacing: CGFloat = 8
var body: some View {
let theKeyboard = [ theModel.allLetters?.filter { $0.keyboardRow == 0 },
theModel.allLetters?.filter { $0.keyboardRow == 1 },
theModel.allLetters?.filter { $0.keyboardRow == 2 }
]
VStack {
ForEach(theKeyboard, id: \.self) { keyboardRow in
HStack(alignment: .top) {
if let keyboardRow = keyboardRow {
ForEach(keyboardRow, id: \.self) { keyboardLetter in
let idx = keyboardLetter.letterStorePosition
LetterView(theIdx: idx, borderColour: .blue)
}
}
}
}
}
}
}
And then the Letter view, for each letter:
struct LetterView: View {
#EnvironmentObject var theModel: MyModel
var theIdx: Int
var borderColour: Color
var spacing: CGFloat = 8
#State private var cellWidth: CGFloat? = nil
func letterFor(letterIdx: Int) -> some View {
Text(String(theModel.allLetters?[letterIdx].letterStoreChar ?? "*"))
.sizePreference(letterIdx: letterIdx)
.frame(width: cellWidth, height: cellWidth, alignment: .center)
.padding(spacing)
}
var body: some View {
self.letterFor(letterIdx: theIdx)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(borderColour, lineWidth: 1)
)
.onPreferenceChange(WidthPreference.self) { self.cellWidth = $0 }
}
}
Finally, for completeness, the Model to store the letters:
class MyModel: ObservableObject {
#Published var allLetters: [LetterData]?
struct LetterData: Hashable {
let letterStorePosition: Int
let letterStoreChar: Character
let keyboardRow: Int
let keyboardCol: Int
}
let keyboardWide = 9 // characters per row
// put all the alphabet characters into an array of LetterData elements
func initializeLetters() {
var tempLetters: [LetterData] = []
let allChars = Array("abcdefghijklmnopqrstuvwxyz")
for (index, element) in allChars.enumerated() {
let row = index / keyboardWide
let col = index % keyboardWide
tempLetters.append(LetterData(letterStorePosition: index, letterStoreChar: element,
keyboardRow: row, keyboardCol: col))
}
allLetters = tempLetters
}
}
Unfortunately, this makes a pretty, yet incorrect grid like this:
Any ideas on where I'm going wrong?
I did some digging, your PreferenceKey is being set with .background which just takes the size of the current View and you are using that value to turn into a square.
There is no match for the average just taking the current width and using it for the height.
extension View {
func sizePreference(letterIdx: Int) -> some View {
background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreference.self, value: proxy.size.width)
})
}
}
.frame(width: cellWidth, height: cellWidth, alignment: .center)
The width is based on the letter I being the most narrow and W being the widest.
Now, how to "fix" your code. You can move the onPreferenceChange up one View and use the min between the current cellWidth and the $0 instead of just replacing.
struct AlphabetGrid: View {
#EnvironmentObject var theModel: MyModel
#State private var cellWidth: CGFloat = .infinity
var spacing: CGFloat = 8
var body: some View {
let theKeyboard = [ theModel.allLetters?.filter { $0.keyboardRow == 0 },
theModel.allLetters?.filter { $0.keyboardRow == 1 },
theModel.allLetters?.filter { $0.keyboardRow == 2 }
]
VStack {
ForEach(theKeyboard, id: \.self) { keyboardRow in
HStack(alignment: .top) {
if let keyboardRow = keyboardRow {
ForEach(keyboardRow, id: \.self) { keyboardLetter in
let idx = keyboardLetter.letterStorePosition
LetterView(theIdx: idx, borderColour: .blue, cellWidth: $cellWidth)
}
}
}
}
} .onPreferenceChange(WidthPreference.self) { self.cellWidth = min(cellWidth, $0 ?? .infinity) }
}
}
Now with that fix you get a better looking keyboard but the M and W are cut off, to use the max you need a little more tweaking, ou can look at the code below.
import SwiftUI
class MyModel: ObservableObject {
#Published var allLetters: [LetterData]?
struct LetterData: Hashable {
let letterStorePosition: Int
let letterStoreChar: Character
let keyboardRow: Int
let keyboardCol: Int
}
let keyboardWide = 9 // characters per row
// put all the alphabet characters into an array of LetterData elements
func initializeLetters() {
var tempLetters: [LetterData] = []
let allChars = Array("abcdefghijklmnopqrstuvwxyz")
for (index, element) in allChars.enumerated() {
let row = index / keyboardWide
let col = index % keyboardWide
tempLetters.append(LetterData(letterStorePosition: index, letterStoreChar: element,
keyboardRow: row, keyboardCol: col))
}
allLetters = tempLetters
}
}
struct AlphabetParentView: View {
#StateObject var theModel = MyModel()
var body: some View {
AlphabetGrid()
.textCase(.uppercase)
.font(.body)
.onAppear() {
theModel.initializeLetters()
}
.environmentObject(theModel)
}
}
struct LetterView: View {
#EnvironmentObject var theModel: MyModel
var theIdx: Int
var borderColour: Color
var spacing: CGFloat = 8
#Binding var cellWidth: CGFloat?
func letterFor(letterIdx: Int) -> some View {
Text(String(theModel.allLetters?[letterIdx].letterStoreChar ?? "*"))
.padding(spacing)
}
var body: some View {
RoundedRectangle(cornerRadius: 8)
.stroke(borderColour, lineWidth: 1)
.overlay {
self.letterFor(letterIdx: theIdx)
}
.frame(width: cellWidth, height: cellWidth, alignment: .center)
}
}
struct AlphabetGrid: View {
#EnvironmentObject var theModel: MyModel
#State private var cellWidth: CGFloat? = nil
var spacing: CGFloat = 8
var body: some View {
let theKeyboard = [ theModel.allLetters?.filter { $0.keyboardRow == 0 },
theModel.allLetters?.filter { $0.keyboardRow == 1 },
theModel.allLetters?.filter { $0.keyboardRow == 2 }
]
VStack {
ForEach(theKeyboard, id: \.self) { keyboardRow in
HStack(alignment: .top) {
if let keyboardRow = keyboardRow {
ForEach(keyboardRow, id: \.self) { keyboardLetter in
let idx = keyboardLetter.letterStorePosition
LetterView(theIdx: idx, borderColour: .blue, cellWidth: $cellWidth)
.sizePreference()
}
}
}
}
} .onPreferenceChange(WidthPreference.self) {
if let w = cellWidth{
self.cellWidth = min(w, $0 ?? .infinity)
}else{
self.cellWidth = $0
}
}
}
}
struct AlphabetParentView_Previews: PreviewProvider {
static var previews: some View {
AlphabetParentView()
}
}
struct WidthPreference: PreferenceKey {
static let defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
extension View {
func sizePreference() -> some View {
background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreference.self, value: proxy.size.width)
})
}
}
There are simpler way of handling this like Ashley's example or SwiftUI.Layout and layout but this should help you understand why your squares were uneven.
Here's a fairly simple implementation, using a GeometryReader to allow us to calculate the width (and therefore the height), of each letter
struct ContentView: View {
let letters = ["ABCDEFGHI","JKLMNOPQR","STUVWXYZ"]
let spacing: CGFloat = 8
var body: some View {
GeometryReader { proxy in
VStack(spacing: spacing) {
ForEach(letters, id: \.self) { row in
HStack(spacing: spacing) {
ForEach(Array(row), id: \.self) { letter in
Text(String(letter))
.frame(width: letterWidth(for: proxy.size.width), height: letterWidth(for: proxy.size.width))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(.cyan, lineWidth: 1)
)
}
}
}
}
}
.padding()
}
func letterWidth(for width: CGFloat) -> CGFloat {
let count = CGFloat(letters.map(\.count).max()!)
return (width - (spacing * (count - 1))) / count
}
}

SwiftUICharts are not redrawn when given new data

I am adding the possibility to swipe in order to update a barchart. What I want to show is statistics for different station. To view different station I want the user to be able to swipe between the stations. I can see that the swiping works and each time I swipe I get the correct data from my controller. The problem is that my view is not redrawn properly.
I found this guide, but cannot make it work.
Say I swipe right from station 0 with data [100, 100, 100] to station 2, the retrieved data from my controller is [0.0, 100.0, 0.0]. The view I have still is for [100, 100, 100]`.
The station number is correctly updated, so I suspect it needs some state somehow.
Here is the code:
import SwiftUI
import SwiftUICharts
struct DetailedResultsView: View {
#ObservedObject var viewModel: ViewModel = .init()
#State private var tabIndex: Int = 0
#State private var startPos: CGPoint = .zero
#State private var isSwiping = true
var body: some View {
VStack {
Text("Station \(viewModel.getStation() + 1)")
TabView(selection: $tabIndex) {
BarCharts(data: viewModel.getData(kLatestRounds: 10, station: viewModel.getStation()), disciplineName: viewModel.getName()).tabItem { Group {
Image(systemName: "chart.bar")
Text("Last 10 Sessions")
}}.tag(0)
}
}.gesture(DragGesture()
.onChanged { gesture in
if self.isSwiping {
self.startPos = gesture.location
self.isSwiping.toggle()
}
}
.onEnded { gesture in
if gesture.location.x - startPos.x > 10 {
viewModel.decrementStation()
}
if gesture.location.x - startPos.x < -10 {
viewModel.incrementStation()
}
}
)
}
}
struct BarCharts: View {
var data: [Double]
var title: String
init(data: [Double], disciplineName: String) {
self.data = data
title = disciplineName
print(data)
}
var body: some View {
VStack {
BarChartView(data: ChartData(points: self.data), title: self.title, style: Styles.barChartStyleOrangeLight, form: CGSize(width: 300, height: 400))
}
}
}
class ViewModel: ObservableObject {
#Published var station = 1
let controller = DetailedViewController()
var isPreview = false
func getData(kLatestRounds: Int, station: Int) -> [Double] {
if isPreview {
return [100.0, 100.0, 100.0]
} else {
let data = controller.getResults(kLatestRounds: kLatestRounds, station: station, fileName: userDataFile)
return data
}
}
func getName() -> String {
controller.getDiscipline().name
}
func getNumberOfStations() -> Int {
controller.getDiscipline().getNumberOfStations()
}
func getStation() -> Int {
station
}
func incrementStation() {
station = (station + 1) % getNumberOfStations()
}
func decrementStation() {
station -= 1
if station < 0 {
station = getNumberOfStations() - 1
}
}
}
The data is printed inside the constructor each time I swipe. Shouldn't that mean it should be updated?
I don’t use SwiftUICharts so I can’t test it, but the least you can try is manually set the id to the view
struct DetailedResultsView: View {
#ObservedObject var viewModel: ViewModel = .init()
#State private var tabIndex: Int = 0
#State private var startPos: CGPoint = .zero
#State private var isSwiping = true
var body: some View {
VStack {
Text("Station \(viewModel.getStation() + 1)")
TabView(selection: $tabIndex) {
BarCharts(data: viewModel.getData(kLatestRounds: 10, station: viewModel.getStation()), disciplineName: viewModel.getName())
.id(viewmodel.station) // here. If it doesn’t work, you can set it to the whole TabView
.tabItem { Group {
Image(systemName: "chart.bar")
Text("Last 10 Sessions")
}}.tag(0)
}
}.gesture(DragGesture()
.onChanged { gesture in
if self.isSwiping {
self.startPos = gesture.location
self.isSwiping.toggle()
}
}
.onEnded { gesture in
if gesture.location.x - startPos.x > 10 {
viewModel.decrementStation()
}
if gesture.location.x - startPos.x < -10 {
viewModel.incrementStation()
}
}
)
}
}

How to update Navigation Link subscript with an array of views inside a ForEach Loop?

I am utilizing a search bar from a Kavsoft Tutorial here: "https://www.youtube.com/watch?v=nuag1PILxCA&t=14s", I'm wondering on how to add navigation links to each of the items, I decided on embedding the itemView inside a navigation link with an array of views to loop through but it seems that it doesn't accept the index as a parameter giving "Cannot convert value of type 'item' to expected argument type 'Int'", instead I incremented the subscript on appear in the navigation link, although that updates the variable, but it doesn't seem to work for the different views themselves only navigating to the first view.
I've linked all the code needed to reproduce the problem but due to my incredibly limited experience in reproducing the problem in as less code as possible, I am not able to do so. Below the main issue of concern is the block starting from the VStack. Starting the program can be done by just adding Search_Bar() to content view body.
struct Home: View {
let views : [AnyView] = [ AnyView(untitled_Skull()), AnyView(dogs()), AnyView(cats()) ]
#Binding var filteredItems : [item]
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
var i = 0
VStack(spacing: 15){
ForEach(filteredItems){index in
NavigationLink(destination: views[i]
) {
itemView(item: index)
}.onAppear() {
i = i + 1
}
}
}
.padding()
}
}
}
func add(value: Int) -> Int {
let value = value + 1
return value
}
struct itemView: View {
var item: item
#State var show = false
var body: some View {
HStack(spacing: 15){
VStack {
let colorArray: [Color] = [.yellowLichtenstien, .redHaring, .orangeBasquiat, .pinkWarhol]
HStack {
Text(item.name)
.foregroundColor(.white)
.bold()
.padding(.leading)
Spacer()
}
HStack {
Text(item.subText)
.bold()
.foregroundColor (.white)
.font(.subheadline)
.padding(.leading)
Circle()
.frame(width: 5, height: 5)
.foregroundColor(colorArray[item.color])
Text(item.subText2)
.bold()
.foregroundColor (.white)
.font(.subheadline)
Spacer()
}
Spacer()
}
}
.padding(.horizontal)
}
}
struct item: Identifiable {
var id = UUID().uuidString
// both Image And Name Are Same....
var name: String
// since all Are Apple Native Apps...
var color: Int
var subText: String
var subText2: String
}
var searchItems = [
item(name: "Untitled (Skull)", color: 0, subText: "1983", subText2: "yay"),
item(name: "Dogs", color: 1, subText: "1972", subText2: "wow"),
item(name: "Cats", color: 2, subText: "1968", subText2: "oof")
]
struct Search_Bar: View {
#State var filteredItems = searchItems
var body: some View {
CustomNavigationView(view: AnyView(Home(filteredItems: $filteredItems)), placeHolder: "Museums, Art or anything else.", largeTitle: true, title: "Search",
onSearch: { (txt) in
if txt != ""{
self.filteredItems = searchItems.filter{$0.name.lowercased().contains(txt.lowercased())}
}
else{
self.filteredItems = searchItems
}
}, onCancel: {
// Do Your Own Code When Search And Canceled....
self.filteredItems = searchItems
})
.ignoresSafeArea()
}
}
struct Search_Bar_Previews: PreviewProvider {
static var previews: some View {
Search_Bar()
}
}
import SwiftUI
struct CustomNavigationView: UIViewControllerRepresentable {
func makeCoordinator() -> Coordinator {
return CustomNavigationView.Coordinator(parent: self)
}
// Just Change Your View That Requires Search Bar...
var view: AnyView
// Ease Of Use.....
var largeTitle: Bool
var title: String
var placeHolder: String
// onSearch And OnCancel Closures....
var onSearch: (String)->()
var onCancel: ()->()
// requre closure on Call...
init(view: AnyView,placeHolder: String? = "Search",largeTitle: Bool? = true,title: String,onSearch: #escaping (String)->(),onCancel: #escaping ()->()) {
self.title = title
self.largeTitle = largeTitle!
self.placeHolder = placeHolder!
self.view = view
self.onSearch = onSearch
self.onCancel = onCancel
}
// Integrating UIKit Navigation Controller With SwiftUI View...
func makeUIViewController(context: Context) -> UINavigationController {
// requires SwiftUI View...
let childView = UIHostingController(rootView: view)
let controller = UINavigationController(rootViewController: childView)
// Nav Bar Data...
controller.navigationBar.topItem?.title = title
controller.navigationBar.prefersLargeTitles = largeTitle
// search Bar....
let searchController = UISearchController()
searchController.searchBar.placeholder = placeHolder
// setting delegate...
searchController.searchBar.delegate = context.coordinator
// setting Search Bar In NavBar...
// disabling hide on scroll...
// disabling dim bg..
searchController.obscuresBackgroundDuringPresentation = false
controller.navigationBar.topItem?.hidesSearchBarWhenScrolling = false
controller.navigationBar.topItem?.searchController = searchController
return controller
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
// Updating Real Time...
uiViewController.navigationBar.topItem?.title = title
uiViewController.navigationBar.topItem?.searchController?.searchBar.placeholder = placeHolder
uiViewController.navigationBar.prefersLargeTitles = largeTitle
}
// search Bar Delegate...
class Coordinator: NSObject,UISearchBarDelegate{
var parent: CustomNavigationView
init(parent: CustomNavigationView) {
self.parent = parent
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
// when text changes....
self.parent.onSearch(searchText)
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
// when cancel button is clicked...
self.parent.onCancel()
}
}
}
Letting the random view below for the array being for example:
import SwiftUI
struct cats: View {
var body: some View {
Text("cats") //replacing this with dogs or untitled skull as an example.
}
}
struct cats_Previews: PreviewProvider {
static var previews: some View {
cats()
}
}
You can use ForEach getting the item and its index in the closure :
ForEach(Array(filteredItems.enumerated()), id: \.1.id) { index, item in
NavigationLink(destination: views[index]){
Text(item.name)
}
}
For example :
struct ListItem: Identifiable {
let id = UUID()
let name: String
}
struct SwiftUIView17: View {
#State private var filteredItems = ["John", "Bob", "Maria"].map(ListItem.init)
let views = [AnyView(Text("John destination")), AnyView(Text("Bob destination")), AnyView(Text("Maria destination"))]
var body: some View {
ScrollView {
ForEach(Array(filteredItems.enumerated()), id: \.1.id) { index, item in
NavigationLink(destination: views[index]){
Text(item.name)
}
}
}
}
}
But it would be better not to use AnyView but a ViewBuilder :
struct SwiftUIView17: View {
#State private var filteredItems = ["John", "Bob", "Maria"].map(ListItem.init)
#ViewBuilder func destination(for itemIndex: Int) -> some View {
switch itemIndex {
case 0: Text("John destination")
case 1: Text("Bob destination").foregroundColor(.red)
case 2: Rectangle()
default: Text("error")
}
}
var body: some View {
ScrollView {
ForEach(Array(filteredItems.enumerated()), id: \.1.id) { index, item in
NavigationLink(destination: destination(for: index)){
Text(item.name)
}
}
}
}
}

SwiftUI List not updating after Data change. (Needs switching the view)

I'm trying to change the SwiftUI list to update after tapping the checkbox button in my list.
When I tap on a list row checkbox button, I call a function to set the immediate rows as checked which ID is less then the selected one. I could modify the ArrayList as selected = 0 to selected = 1. But as my list is Published var it should emit the change to my list view. but it doesn't.
Here's what I've done:
// ViewModel
import Foundation
import Combine
class BillingViewModel: ObservableObject {
#Published var invoiceList = [InvoiceModel](){
didSet {
self.didChange.send(true)
}
}
#Published var shouldShow = true
let didChange = PassthroughSubject<Bool, Never>()
init() {
setValues()
}
func setValues() {
for i in 0...10 {
invoiceList.append(InvoiceModel(ispID: 100+i, selected: 0, invoiceNo: "Invoice No: \(100+i)"))
}
}
func getCombinedBalance(ispID: Int) {
DispatchQueue.main.async {
if let row = self.invoiceList.firstIndex(where: {$0.ispID == ispID}) {
self.changeSelection(row: row)
}
}
}
func changeSelection(row: Int) {
if invoiceList[row].selected == 0 {
let selectedRows = invoiceList.map({ $0.ispID ?? 0 <= invoiceList[row].ispID ?? 0 })
print(selectedRows)
for index in 0..<invoiceList.count {
if selectedRows[index] {
invoiceList[index].selected = 1
} else {
invoiceList[index].selected = 0
}
}
} else {
let selectedRows = invoiceList.map({ $0.ispID ?? 0 <= invoiceList[row].ispID ?? 0 })
print(selectedRows)
for index in 0..<invoiceList.count {
if selectedRows[index] {
invoiceList[index].selected = 1
} else {
invoiceList[index].selected = 0
}
}
}
}
}
// List View
import SwiftUI
struct ContentView: View {
#StateObject var billingViewModel = BillingViewModel()
#State var shouldShow = true
var body: some View {
NavigationView {
ZStack {
VStack {
List {
ForEach(billingViewModel.invoiceList) { invoice in
NavigationLink(
destination: InvoiceDetailsView(billingViewModel: billingViewModel)) {
invoiceRowView(billingViewModel: billingViewModel, invoice: invoice)
}
}
}
}
}.navigationBarTitle(Text("Invoice List"))
}
}
}
// Invoice Row View
import SwiftUI
struct invoiceRowView: View {
#StateObject var billingViewModel: BillingViewModel
#State var invoice: InvoiceModel
var body: some View {
VStack(alignment: .leading) {
HStack {
Button(action: {
if invoice.selected == 0 {
print(invoice)
billingViewModel.getCombinedBalance(ispID: invoice.ispID ?? 0)
} else {
print(invoice)
billingViewModel.getCombinedBalance(ispID: invoice.ispID ?? 0)
}
}, label: {
Image(systemName: invoice.selected == 0 ? "checkmark.circle" : "checkmark.circle.fill")
.resizable()
.frame(width: 32, height: 32, alignment: .center)
}).buttonStyle(PlainButtonStyle())
Text(invoice.invoiceNo ?? "Hello, World!").padding()
Spacer()
}
}
}
}
// Data Model
import Foundation
struct InvoiceModel: Codable, Identifiable {
var id = UUID()
var ispID: Int?
var selected: Int?
var invoiceNo: String?
}
You need to use binding instead of externally injected state (which is set just to copy of value), i.e.
struct invoiceRowView: View {
#StateObject var billingViewModel: BillingViewModel
#Binding var invoice: InvoiceModel
// .. other code
and thus inject binding in parent view
ForEach(billingViewModel.invoiceList.indices, id: \.self) { index in
NavigationLink(
destination: InvoiceDetailsView(billingViewModel: billingViewModel)) {
invoiceRowView(billingViewModel: billingViewModel, invoice: $billingViewModel.invoiceList[index])
}
}

SwiftUI/PreferenceKey: How to avoid moving rects when scrolling?

I'm working on a tab bar that is scrollable and that has a moving background for the selected tab.
The solution is based on PreferenceKeys; however, I have a problem to get the moving background stable in relation to the tabs. Currently, it moves when scrolling, which is not desired; instead, it shall be fixed in relation to the tab item and scroll with them.
Why is this the case, and how to avoid that? When removing the ScrollView, the background moves correctly to the selected tab item. The TabItemButton is just a Button with some special label.
struct TabBar: View {
#EnvironmentObject var service: IRScrollableTabView.Service
// We support up to 15 items.
#State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 15)
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: geo.size.width))
.animation(.easeInOut(duration: 0.3))
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = selectedRect.minX + selectedRect.size.width / 2 - width / 2
debugPrint(selectedOffset)
return selectedOffset
}
}
struct Setter: View {
let index: Int
var body: some View {
GeometryReader { geo in
Rectangle()
.fill(Color.clear)
.preference(key: IRPreferenceKey.self,
value: [IRData(viewIndex: self.index,
rect: geo.frame(in: .named("IRReference")))])
}
}
}
struct IRPreferenceKey: PreferenceKey {
typealias Value = [IRData]
static var defaultValue: [IRScrollableTabView.IRData] = []
static func reduce(value: inout [IRScrollableTabView.IRData], nextValue: () -> [IRScrollableTabView.IRData]) {
value.append(contentsOf: nextValue())
}
}
struct IRData: Equatable {
let viewIndex: Int
let rect: CGRect
}
The service is defined this way (i.e., nothing special...):
final class Service: ObservableObject {
#Published var currentDestinationView: AnyView
#Published var tabItems: [IRScrollableTabView.Item]
#Published var selectedIndex: Int { didSet { debugPrint("selectedIndex: \(selectedIndex)") } }
init(initialDestinationView: AnyView,
tabItems: [IRScrollableTabView.Item],
initialSelectedIndex: Int) {
self.currentDestinationView = initialDestinationView
self.tabItems = tabItems
self.selectedIndex = initialSelectedIndex
}
}
struct Item: Identifiable {
var id: UUID = UUID()
var title: String
var image: Image = Image(systemName: "circle")
}
I solved the problem! The trick seemed to be to put another GeometryReader around the Indicator view and to take its width for calculating the offset. The .onPreferenceChange must be attached to the HStack, and the .coordinateSpace to the ZStack. Now it's working...
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
GeometryReader { innerGeo in
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: innerGeo.size.width))
.animation(.easeInOut(duration: 0.3))
}
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = -width / 2 + CGFloat(80 * self.service.selectedIndex) + selectedRect.size.width / 2
debugPrint(selectedOffset)
return selectedOffset
}