didSelectItemAt causes UI to reflow/redraw every time value for lastSelectedIndex is changed, causing performance issue. I'm not sure if I have used #State properly to propagate value from child to parent.
P.S. I need to use UICollectionView for a reason instead of swiftui List or ScrollView.
import Foundation
import SwiftUI
struct ContentView: View {
#State var lastSelectedIndex : Int = -1
var body: some View {
ZStack {
CustomCollectionView(lastSelectedIndex: $lastSelectedIndex)
Text("Current Selected Index \(lastSelectedIndex)")
}
}
}
struct CustomCollectionView: UIViewRepresentable {
#Binding var lastSelectedIndex : Int
func makeUIView(context: Context) -> UICollectionView {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.itemSize = CGSize(width: 400, height: 300)
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: CustomCollectionViewCell.reuseId)
collectionView.delegate = context.coordinator
collectionView.dataSource = context.coordinator
collectionView.backgroundColor = .systemBackground
collectionView.isDirectionalLockEnabled = true
collectionView.backgroundColor = UIColor.black
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
collectionView.alwaysBounceVertical = false
return collectionView
}
func updateUIView(_ uiView: UICollectionView, context: Context) {
uiView.reloadData()
}
func makeCoordinator() -> CustomCoordinator {
CustomCoordinator(self)
}
}
class CustomCoordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
let parent:CustomCollectionView
init(_ parent:CustomCollectionView) {
self.parent = parent
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
100
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomCollectionViewCell.reuseId, for: indexPath) as! CustomCollectionViewCell
cell.backgroundColor = UIColor.red
cell.label.text = "Current Index is \(indexPath.row)"
NSLog("Called for Index \(indexPath.row)")
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
parent.lastSelectedIndex = indexPath.row
}
}
class CustomCollectionViewCell: UICollectionViewCell {
static let reuseId = "customCell"
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
label.numberOfLines = 0
contentView.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Quick fix
You can use an ObservableObject for this purpose
class Model: ObservableObject {
#Published var index: Int
init(index: Int) { self.index = index }
}
Create a container view for your text field that will update when this model changes:
struct IndexPreviewer: View {
#ObservedObject var model: Model
var body: Text { Text("Current Selected Index \(model.index)") }
}
Then include this model and the observer in your ContentView:
struct ContentView: View {
private let model = Model(index: -1)
var body: some View {
VStack {
IndexPreviewer(model: model)
CustomCollectionView(lastSelectedIndex: index)
}
}
var index: Binding<Int> {
Binding {model.index} set: {model.index = $0}
}
}
Explanation
The problem is that once you update a #State property, the containing view's body will be re-evaluated. So you cannot create a #State property on the view that contains your collection view, because each time you select a different cell, a message will be sent to your container who will re-evaluate it's body that contains the collection view. Hence the collection view will refresh and reload your data like Asperi wrote in his answer.
So what can you do to resolve that? Remove the state property wrapper from your container view. Because when you update the lastSelectedIndex, your container view (ContentView) should not be rendered again. But your Text view should be updated. So you should wrap your Text view in a separate view that observes the selection index.
This is where ObservableObject comes in to play. It is a class that can store State data on itself instead of being stored directly in a property of a view.
So why does IndexPreviewer update when the model changes and ContentView not, you might ask? That is because of the #ObservedObject property wrapper. Adding this to a view will refresh the view when the associated ObservableObject changes. That is why we do not include #ObservedObject inside ContentView but we do include it in IndexPreviewer.
How/Where to store your models?
For the sake of simplicity I added the model as a constant property to ContentView. This is however not a good idea when ContentView is not the root view of your SwiftUI hierarchy.
Say for example that your content view also receives a Bool value from its parent:
struct Wrapper: View {
#State var toggle = false
var body: some View {
VStack {
Toggle("toggle", isOn: $toggle)
ContentView(toggle: toggle)
}
}
}
struct ContentView: View {
let toggle: Bool
private let model = Model(index: -1)
...
}
When you run that on iOS 13 or 14 and try to click on collection view cell and then change the toggle, you will see that the selected index will reset to -1 when you change the toggle. Why does this happen?
When you click on the toggle, the #State var toggle will change and since it uses the #State property wrapper the body of the view will be recomputed. So another ContentView will be constructed and with it, also a new Model object.
There are two ways to prevent this from happening. One way is to move your model up in the hierarchy. But this can create a cluttered root view at the top of your view hierarchy. In some cases it is better to leave transient UI state local to your containing UI component. This can be achieved by an undocumented trick which is to use #State for your model objects. Like I said, it is currently (july 2020) undocumented but properties wrapped using #State will persist their value accross UI updates.
So to make a long story short: You should probably be storing your model using:
struct ContentView: View {
#State private var model = Model(index: -1)
It is a feature of #State to cause dependent view refresh. In case of representable changing dependent state calls updateUIView, so, as you put reloadData in it - its reloaded:
func updateUIView(_ uiView: UICollectionView, context: Context) {
// Either remove reload from here (ig. make it once in makeUIView to load
// content, or make reload here only conditionally depending on some parameter
// which really needs collection to be reloaded
// uiView.reloadData()
}
Related
How can one prevent auto-zooming with IOS MapKit when using userTrackingMode = .followWithHeading? That is I am setting the current user location to the centre of the screen, and have "view.userTrackingMode = .followWithHeading" so that the map orientates to north, but when you zoom in/out manually the MapView automatically overrides this and zooms back to the level it seems to prefer being at.
I was to be able to zoom in, then the zoom level stays like this, whilst it keeps the map centred to user location, and keeps auto-rotating to keep map aligned to north.
I am using SwiftUI so have effectively do have the location being passed into GCMapView as a parameter (as the means to keep the SwiftUI GCMapView up to date with latest user location). So not sure if this is causing an issue?
Some key bits (have pulled some code out to show relevant lines) of the MapKit call backs I'm using:
struct FlightView: View {
#EnvironmentObject var locationManager : GCLocationManager
#State var centreUserLocation : Bool = false
var body: some View {
GCMapView(
flight: flight,
userLocation: locationManager.userLocation,
centreUserLocation: centreUserLocation,
initalZoomDone: initalZoomDone
)
}
}
struct GCMapView : UIViewRepresentable {
let map = MKMapView()
func makeUIView(context: Context) -> MKMapView {
map.delegate = context.coordinator
map.isRotateEnabled = true
map.userTrackingMode = .followWithHeading
map.showsUserLocation = true
return map
}
func updateUIView(_ view: MKMapView, context: Context) {
if let userLocation = userLocation {
view.centerCoordinate = userLocation
view.userTrackingMode = .followWithHeading // Needed to keep map rotating to align to North
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: GCMapView
init(_ parent: GCMapView) {
self.parent = parent
super.init()
}
}
}
i tried to make a view like bellow in SwiftUi without any success Customized info window swift ui
Since this question doesn't have too much detail, I will be going off of some assumptions. First, I am assuming that you are calling the MapView through a UIViewControllerRepresentable.
I am not too familiar with the Google Maps SDK, but this is possible through the GMSMapViewDelegate Methods. After implementing the proper GMSMapViewDelegate method, you can use ZStacks to present the image that you would like to show.
For example:
struct MapView: UIViewControllerRepresentable {
var parentView: ContentView
func makeUIViewController(context: Context) {
let mapView = GMSMapView()
return mapView
}
func updateUIViewController(_ uiViewController: GMSMapView, context: Context) {
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, GMSMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
//Use the proper Google Maps Delegate method to find out if a marker was tapped and then show the image by doing: parent.parentView.isShowingInformationImage = true.
}
}
In your SwiftUI view that you would like to put this MapView in, you can do the following:
struct ContentView: View {
#State var isShowingInformationImage = false
var body: some View {
ZStack {
if isShowingInformationImage {
//Call the View containing the image
}
MapView(parentView: self)
}
}
}
I have made a simple UIViewRepresentable from MKMapView. You can scroll the mapview, and the screen will be updated with the coordinates in the middle.
Here's the ContentView:
import SwiftUI
import CoreLocation
let london = CLLocationCoordinate2D(latitude: 51.50722, longitude: -0.1275)
struct ContentView: View {
#State private var center = london
var body: some View {
VStack {
MapView(center: self.$center)
HStack {
VStack {
Text(String(format: "Lat: %.4f", self.center.latitude))
Text(String(format: "Long: %.4f", self.center.longitude))
}
Spacer()
Button("Reset") {
self.center = london
}
}.padding(.horizontal)
}
}
}
Here's the MapView:
struct MapView: UIViewRepresentable {
#Binding var center: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
uiView.centerCoordinate = self.center
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.center = mapView.centerCoordinate
}
init(_ parent: MapView) {
self.parent = parent
}
}
}
Tapping the reset button should simply set mapView.center to london. The current method will make the map scrolling super slow, and when the button is tapped, cause the error "Modifying state during view update, this will cause undefined behavior."
How should resetting the coordinates be communicated to the MKMapView, such that the map scrolling is fast again, and the error is fixed?
The above solution with an ObservedObject will not work. While you wont see the warning message anymore, the problem is still occurring. Xcode just isn't able to warn you its happening anymore.
Published properties in ObservableObjects behave almost identically to #State and #Binding. That is, they trigger a view update any time their objectWillUpdate publisher is triggered. This happens automatically when an #Published property is updated. You can also trigger it manually yourself with objectWillChange.send()
Because of this, it is possible to make properties that do not automatically cause view state to update. And we can leverage this to prevent unwanted state updates for UIViewRepresentable and UIViewControllerRepresentable structs.
Here is an implementation that will not loop when you update its view model from the MKMapViewDelegate methods:
struct MapView: UIViewRepresentable {
#ObservedObject var viewModel: Self.ViewModel
func makeUIView(context: Context) -> MKMapView{
let mapview = MKMapView()
mapview.delegate = context.coordinator
return mapview
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// Stop update loop when delegate methods update state.
guard viewModel.shouldUpdateView else {
viewModel.shouldUpdateView = true
return
}
uiView.centerCoordinate = viewModel.centralCoordinate
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
private var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView){
// Prevent the below viewModel update from calling itself endlessly.
parent.viewModel.shouldUpdateView = false
parent.viewModel.centralCoordinate = mapView.centerCoordinate
}
}
class ViewModel: ObservableObject {
#Published var centerCoordinate: CLLocationCoordinate2D = .init(latitude: 0, longitude: 0)
var shouldUpdateView: Bool = true
}
}
If you really dont want to use an ObservableObject, the alternative is to put the shouldUpdateView property into your coordinator. Although I still prefer to use a viewModel because it keeps your UIViewRepresentable free of multiple #Bindings. You can also use the ViewModel externally and listen to it via combine.
Honestly, I'm surprised apple didn't consider this exact issue when they created UIViewRepresentable.
Almost all UIKit views will have this exact problem if you need to keep your SwiftUI state in sync with view changes.
I keep finding very strange SwiftUI bugs that only pop up under very specific circumstances 😅. For example, I have a form that is shown as a model sheet. This form has a ViewModel, and shows a UITextView (via UIViewRepresentable and a #Binding - it's all in the code below).
Everything works absolutely fine, you can run the code below and you'll see all the two-way bindings working as expected: type in one field and it changes in the other, and vice-versa. However, as soon as you un-comment the line #Environment(\.presentationMode) private var presentationMode, then the two-way binding in the TextView breaks. You will also notice that the ViewModel prints "HERE" twice.
What the hell is going on? My guess is that as soon as ContentView shows the modal, the value of presentationMode changes, which then re-renders the sheet (so, FormView). That would explain the duplicate "HERE" getting logged. But, why does that break the two-way text binding?
One workaround is to not use a ViewModel, and simply have an #State property directly in the FormView. But that is not a great solution as I have a bunch of logic in my real-world form, which I don't want to move to the form view. So, does anyone have a better solution?
import SwiftUI
import UIKit
struct TextView: UIViewRepresentable {
#Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let uiTextView = UITextView()
uiTextView.delegate = context.coordinator
return uiTextView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = self.text
}
class Coordinator : NSObject, UITextViewDelegate {
var parent: TextView
init(_ view: TextView) {
self.parent = view
}
func textViewDidChange(_ textView: UITextView) {
self.parent.text = textView.text
}
func textViewDidEndEditing(_ textView: UITextView) {
self.parent.text = textView.text
}
}
}
struct ContentView: View {
#State private var showForm = false
//#Environment(\.presentationMode) private var presentationMode
var body: some View {
NavigationView {
Text("Hello")
.navigationBarItems(trailing: trailingNavigationBarItem)
}
.sheet(isPresented: $showForm) {
FormView()
}
}
private var trailingNavigationBarItem: some View {
Button("Form") {
self.showForm = true
}
}
}
struct FormView: View {
#ObservedObject private var viewModel = ViewModel()
var body: some View {
NavigationView {
Form {
Section(header: Text(viewModel.text)) {
TextView(text: $viewModel.text)
.frame(height: 200)
}
Section(header: Text(viewModel.text)) {
TextField("Text", text: $viewModel.text)
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var text = ""
init() {
print("HERE")
}
}
I finally found a workaround: store the ViewModel on the ContentView, not on the FormView, and pass it in to the FormView.
struct ContentView: View {
#State private var showForm = false
#Environment(\.presentationMode) private var presentationMode
private let viewModel = ViewModel()
var body: some View {
NavigationView {
Text("Hello")
.navigationBarItems(trailing: trailingNavigationBarItem)
}
.sheet(isPresented: $showForm) {
FormView(viewModel: self.viewModel)
}
}
private var trailingNavigationBarItem: some View {
Button("Form") {
self.showForm = true
}
}
}
struct FormView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
Form {
Section(header: Text(viewModel.text)) {
TextView(text: $viewModel.text)
.frame(height: 200)
}
Section(header: Text(viewModel.text)) {
TextField("Text", text: $viewModel.text)
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var text = ""
init() {
print("HERE")
}
}
The only thing is that the ViewModel is now instantiated right when the ContentView is opened, even if you never open the FormView. Feels a bit wasteful. Especially when you have a big List, with NavigationLinks to a bunch of detail pages, which now all create their presented-as-a-sheet FormView's ViewModel up front, even if you never leave the List page.
Sadly I can't turn the ViewModel into a struct, as I actually need to (asynchronously) mutate state and then eventually I run into the Escaping closure captures mutating 'self' parameter compiler error. Sigh. So yeah, I am stuck with using a class.
The root of the issue is still that FormView is instantiated twice (because of #Environment(\.presentationMode)), which causes two ViewModels to be created as well (which my workaround solves by passing in one copy to both FormViews basically). But it's still weird that this broke #Binding, since the standard TextFields did work as expected.
There are still a lot of weird gotcha's like this with SwiftUI, I really hope this becomes simpler to manage soon. If anyone can explain the behavior of sheets, ObservableObject classes (viewmodels), #Environment(\.presentationMode) and #Binding put together, I'm all ears.
To make UI-based editing of a NSAttributedString property (in a managed object) possible, a UITextView is used instead of a SwiftUI TextField View. The text view is located in a modal view being presented by a sheet function.
.sheet(isPresented: $presentSheet) { ...
(to illustrate and reproduce, the code below is a simplified version of this scenario)
The modal view is used to edit a selected model item that is shown in a list through a ForEach construct. The selected model item is passed as an #Observable object to the modal view.
When selecting an item "A", the modal view and the UITextView correctly shows this model item. If selecting a new item "B", the modal view correctly shows this "B" item. But if "B" is now being edited the change will affect the "A" object.
The reason for this behaviour is probably that the UIViewRepresentable view (representing the UITextView) is only initialised once. Further on from here, this seems to be caused by the way a sheet (modal) view is presented in SwiftUI (state variables are only initialised when the sheet first appear, but not the second time).
I am able to fix this malfunction by passing the selected item as a #Binding instead of an #Observable object, although I am not convinced that this is the right way to handle the situation, especially because everything works nicely, if a SwiftUI TextField is used instead of the UITextView (in the simplified case).
Worth mentioning, I seems to have figured out, what goes wrong in the case with the UITextView - without saying that this solves the problem.
In the code listed below (which repro the problem), the Coordinator's init function has one assignment that initialises the Coordinator with the parent. Since this is value and not a reference assignment, and since the Coordinator only get initialised once, an edit of the UITextView will likely access a wrong parent.
Again, I am not certain about my solution to the problem, is the right one, since everything works fine when using a SwiftUI TextField instead. I therefore hope to see some comments on this problem.
struct ContentView: View {
var states = [StringState("A"), StringState("B"), StringState("C"), StringState("D"), StringState("E")]
#State var presentSheet = false
#State var state = StringState("A")
var body: some View {
VStack {
Text("state = \(state.s)")
ForEach(states) { s in
Button(action: {
self.state = s
self.presentSheet.toggle()
})
{
Text("\(s.s)")
}
}
}
.sheet(isPresented: $presentSheet) {
EditView(state: self.state, presentSheet: self.$presentSheet)
}
}
}
struct EditView: View
{
#ObservedObject var state: StringState
#Binding var presentSheet: Bool
var body: some View {
VStack {
Text("\(state.s)")
TextView(string: $state.s) // Edit Not OK
TextField("", text: $state.s ) // Edit OK
Button(action: {
self.presentSheet.toggle()
})
{ Text("Back") }
}
}
}
struct TextView: UIViewRepresentable
{
#Binding var string: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView
{
let textview = UITextView(frame: CGRect.zero)
textview.delegate = context.coordinator
return textview
}
func updateUIView(_ uiView: UITextView, context: Context)
{
uiView.text = string
}
class Coordinator : NSObject, UITextViewDelegate
{
var parent: TextView
init(_ textView: TextView) {
self.parent = textView
}
func textViewDidChange(_ textView: UITextView)
{
self.parent.string = textView.text!
}
}
}
class StringState: Identifiable, ObservableObject
{
let ID = UUID()
var s: String
init(_ s : String) {
self.s = s
}
}
A couple of changes will fix it:
func updateUIView(_ uiView: UITextView, context: Context)
{
uiView.text = string
context.coordinator.parent = self
}
And also add #Published to your ObservableObject:
class StringState: Identifiable, ObservableObject
{
let ID = UUID()
#Published var s: String
init(_ s : String) {
self.s = s
}
}