I have a view that depends both on a #ObservedObject and a #State.
In a Button action, I modify them both, and this leads to a crash. If I modify only the #ObservedObject, then everything is OK (except, I don't get the intended behavior, of course).
public struct PointListEditorView: View {
#ObservedObject var viewModel: PointListEditorViewModel
#State var selectedCellIndex: Int = NO_CELL_SELECTED_INDEX
public var body: some View {
(...)
Button(action: {
let savedSelectedCellIndex = self.selectedCellIndex
self.selectedCellIndex = NO_CELL_SELECTED_INDEX // No crash if I remove this line
self.viewModel.removePointEditorViewModel(at: savedSelectedCellIndex)
},
label: (...)
What happens is that I have a crash in the body of a subview after the call to removePointEditorViewModel. This body goes through an array of objects that is modified by removePointEditorViewModel. removePointEditorViewModel triggers the #Published variables of the #ObservedObject.
The same thing happens if I invert both lines like this :
self.viewModel.removePointEditorViewModel(at: savedSelectedCellIndex)
self.selectedCellIndex = NO_CELL_SELECTED_INDEX//##
I first thought there would be some kind of strange interference between #State and #ObservedObject, but the first answers (thanks guys) pointed me in another direction.
Edit to provide more information
Edit 2 to make the title and the rest consistent with the current investigations
Here is the hierarchy of my views :
PointListEditorView
+ PointListEditorContentView
+ PointListEditorCellView (n times)
The PointListEditorView has this selectedCellIndex #State. This state is binded by PointListEditorContentView and PointListEditorCellView. It is modified by PointListEditorCellView through a tapGesture.
I have logged the entry and exit of the body computation. I have also logged the creation of PointListEditorCellView, and when I deleted things in my model. I have spotted some strange things.
**APP START**
Enter PointListEditorView body
Exit PointListEditorView body
Enter PointListEditorContentView body
count of pointEditorViewModels : 0
count of pointEditorViewModelsAndIndex : 0
End preparation PointListEditorContentView body
Exit PointListEditorContentView body
**ADD ONE CELL**
Enter PointListEditorContentView body
count of pointEditorViewModels : 1
map call with index : 0
count of pointEditorViewModelsAndIndex : 1
End preparation PointListEditorContentView body
Exit PointListEditorContentView body
created PointListEditorCellView : 35779A71-811F-42DD-A803-3C0E82C3CAD8
Enter PointListEditorCellView body : 35779A71-811F-42DD-A803-3C0E82C3CAD8
Exit PointListEditorCellView body
Enter PointListEditorView body
Exit PointListEditorView body
All this looks pretty normal to me. But now :
**SELECT THE CELL**
Enter PointListEditorView body
Exit PointListEditorView body
Enter PointListEditorContentView body
count of pointEditorViewModels : 1
map call with index : 0
count of pointEditorViewModelsAndIndex : 1
End preparation PointListEditorContentView body
Exit PointListEditorContentView body
Enter PointListEditorCellView body : 35779A71-811F-42DD-A803-3C0E82C3CAD8
<== Why the hell do we have that ???
This is the cell view created at previous step. It
should be forgotten and replaced by another one
as seen below
Exit PointListEditorCellView body
created PointListEditorCellView : 2EA80249-67B6-46A0-88C9-C5F5E8FEAE80
Enter PointListEditorCellView body : 2EA80249-67B6-46A0-88C9-C5F5E8FEAE80
Exit PointListEditorCellView body
**DELETE THE CELL**
Enter delete callback
Delete model element
#Published var modified, the view is notified
Exit delete callback
Enter PointListEditorView body
Exit PointListEditorView body
Enter PointListEditorContentView body
count of pointEditorViewModels : 0
count of pointEditorViewModelsAndIndex : 0
<== This shows there is nothing in the model, there should be
no cell view created - just like during app init
End preparation PointListEditorContentView body
Exit PointListEditorContentView body
Enter PointListEditorCellView body : 2EA80249-67B6-46A0-88C9-C5F5E8FEAE80
Fatal error: Index out of range: file /AppleInternal/BuildRoot/Library/Caches/com.apple.xbs/Sources/swiftlang/swiftlang-1103.8.25.8/swift/stdlib/public/core/ContiguousArrayBuffer.swift, line 444
<== This "ghost" cell is refering to a model elemnt that
has been deleted, os of course there is a crash, but why
does this cell still exist ???
After some more investigations, I managed to spot that the "retain" only happens when I have a tapGesture attached to the cell view. This is done in the PointListEditorContentView view this way :
struct PointListEditorCellView: View
var pointEditorViewModel: PointEditorBaseViewModel
var index: Int
#Binding var selectedCellIndex: Int
var body: some View {
VStack(spacing: 3.0) {
PointEditorView(pointEditorViewModel)
.onTapGesture {
if (self.selectedCellIndex == self.index) {
self.selectedCellIndex = NO_CELL_SELECTED_INDEX
} else {
self.selectedCellIndex = self.index
}
}
}
}
If I remove the .gesture, I don't see the ghost appear.
There is no interference between #State and #ObservedObject.
With the little code you provided, I would look hard into the
self.viewModel.removePointEditorViewModel(at: savedSelectedCellIndex)
is the index (savedSelectedCellIndex) valid for that model?
I finally found a solution.
From my point of view, this "ghost" views are the consequence of a bug inside SwiftUI. I came to this conclusion after flattening the view hierarchy, and suddenly, everything went fine.
I had the following code :
struct PointListEditorContentView: View {
#ObservedObject var viewModel: PointListEditorBaseViewModel
init(_ vm: PointListEditorBaseViewModel) {
self.viewModel = vm
}
var body: some View {
let pointEditorViewModels = viewModel.pointEditorViewModelsList()
let pointEditorViewModelsAndIndex = pointEditorViewModels.enumerated().map { (index, vm) in
return (vm: vm,
index: index)
}
return VStack(spacing: 3.0) {
ForEach(pointEditorViewModelsAndIndex, id: \.vm.id) { pointEditorViewModel in
PointListEditorCellView(pointListEditorViewModel: self.viewModel,
pointEditorViewModel: pointEditorViewModel.vm,
index: pointEditorViewModel.index)
}
PointListEditorDropableView(viewModel: viewModel,
positionOfDroppedItem: pointEditorViewModels.count)
}
.padding()
}
}
struct PointListEditorCellView: View {
#ObservedObject var pointListEditorViewModel: PointListEditorBaseViewModel
#ObservedObject var pointEditorViewModel: PointEditorBaseViewModel
var index: Int
var body: some View {
VStack(spacing: 3.0) {
PointListEditorDropableView(viewModel: pointListEditorViewModel,
positionOfDroppedItem: index)
.fixedSize(horizontal: false, vertical: true)
PointEditorView(pointEditorViewModel)
.shadow(color: (self.pointListEditorViewModel.selectedCellIndex == index ? Color.accentColor : Color.clear),
radius: (self.pointListEditorViewModel.selectedCellIndex == index ? 5.0 : 0.0))
.onTapGesture {
if (self.pointListEditorViewModel.selectedCellIndex == self.index) {
self.pointListEditorViewModel.selectedCellIndex = NO_CELL_SELECTED_INDEX
} else {
self.pointListEditorViewModel.selectedCellIndex = self.index
}
}
.onDrag {
let itemProvider = NSItemProvider()
itemProvider.registerObject(PointListEditorItemProvider(initialIndex: self.index),
visibility: .ownProcess)
return itemProvider
}
}
}
}
I just moved the content of the PointListEditorCellView body inside the ForEach like the following code, and everything went suddenly fine.
struct PointListEditorContentView: View {
#ObservedObject var viewModel: PointListEditorBaseViewModel
init(_ vm: PointListEditorBaseViewModel) {
self.viewModel = vm
}
var body: some View {
let pointEditorViewModels = viewModel.pointEditorViewModelsList()
let pointEditorViewModelsAndIndex = pointEditorViewModels.enumerated().map { (index, vm) in
return (vm: vm,
index: index)
}
return VStack(spacing: 3.0) {
ForEach(pointEditorViewModelsAndIndex, id: \.vm.id) { pointEditorViewModel in
VStack(spacing: 3.0) {
PointListEditorDropableView(viewModel: self.viewModel,
positionOfDroppedItem: pointEditorViewModel.index)
.fixedSize(horizontal: false, vertical: true)
PointEditorView(pointEditorViewModel.vm)
.shadow(color: (self.viewModel.selectedCellIndex == pointEditorViewModel.index ?
Color.accentColor : Color.clear),
radius: (self.viewModel.selectedCellIndex == pointEditorViewModel.index ? 5.0 : 0.0))
.onTapGesture {
if (self.viewModel.selectedCellIndex == pointEditorViewModel.index) {
self.viewModel.selectedCellIndex = NO_CELL_SELECTED_INDEX
} else {
self.viewModel.selectedCellIndex = pointEditorViewModel.index
}
}
.onDrag {
let itemProvider = NSItemProvider()
itemProvider.registerObject(PointListEditorItemProvider(initialIndex:
pointEditorViewModel.index),
visibility: .ownProcess)
return itemProvider
}
}
}
PointListEditorDropableView(viewModel: viewModel,
positionOfDroppedItem: pointEditorViewModels.count)
}
.padding()
}
}
I am documenting this in SO, so that if anybody has a different (and better explanation than just "it's a SwiftUI bug"), we can record the discussion.
Related
I am having trouble getting the SwiftUI TextEditor to work when it is in a Child View.
This is a small example that demonstrates the issue for me:
import SwiftUI
struct ContentView: View {
#State private var someText: String = "Hello World"
var body: some View {
VStack {
HStack {
Button("Text 1", action: {someText = "hello"})
Button("Text 2", action: {someText = "world"})
}
ViewWithEditor(entry: $someText)
}
}
}
struct ViewWithEditor: View {
#Binding var entry: String
#State private var localString: String
var body: some View
{
VStack {
TextEditor(text: $localString)
}
}
init(entry: Binding<String>) {
self._entry = entry
self._localString = State(initialValue: entry.wrappedValue)
print("init set local String to: \(localString)")
}
}
When I click the buttons I expected the Editor text to change, however it remains with its initial value.
The print statement show that the "localString" variable is being updated.
Is TextEditor broken or am I missing something fundamental ??
If you move the buttons into the same view as the TextEditor, directly changing local state var it works as expected.
This is being run under MacOS in case it makes a difference.
TIA Alan.
Ok. So a proxy Binding does the job for me. See updated editor view below:
struct ViewWithEditor: View {
#Binding var entry: String
var body: some View
{
let localString = Binding<String> (
get: {
entry
},
set: {
entry = $0
}
)
return VStack {
Text(entry)
TextEditor(text: localString)
}
}
A bit more ugly (proxy bindings just seem clutter), but in some ways simpler..
It allows for the result of the edit to be reviewed / rejected before being pushed into the bound var.
This is occurring because the binding entry var is not actually being used after initialization of ViewWithEditor. In order to make this work without using the proxy binding add onChange to the ViewWithEditor as below:
struct ViewWithEditor: View {
#Binding var entry: String
#State private var localString: String
var body: some View
{
VStack {
TextEditor(text: $localString)
}
.onChange(of: entry) {
localString = $0
}
}
init(entry: Binding<String>) {
self._entry = entry
self._localString = State(initialValue: entry.wrappedValue)
print("init set local String to: \(localString)")
}
}
The problem here is that now entry is not updating if localString changes. One could just use the same approach as before:
var body: some View
{
VStack {
TextEditor(text: $localString)
}
.onChange(of: entry) {
localString = $0
}
.onChange(of: localString) {
entry = $0
}
}
but why not just use $entry as the binding string for TextEditor?
I have the following code:
import SwiftUI
enum OptionButtonRole {
case normal
case destructive
case cancel
}
struct OptionButton {
var title: String
var role: OptionButtonRole
let id = UUID()
var action: () -> Void
}
struct OptionSheet: ViewModifier {
#State var isPresented = true
var title: String
var buttons: [OptionButton]
init(title: String, buttons: OptionButton...) {
self.title = title
self.buttons = buttons
}
func body(content: Content) -> some View {
content
.confirmationDialog(title,
isPresented: $isPresented,
titleVisibility: .visible) {
ForEach(buttons, id: \.title) { button in
let role: ButtonRole? = button.role == .normal ? nil : button.role == .destructive ? .destructive : .cancel
Button(button.title, role: role, action: button.action)
}
}
}
}
It builds and my app shows the option sheet with the specified buttons.
However, if I use an alternative Button.init, i.e. if I replace the body with the following code:
func body(content: Content) -> some View {
content
.confirmationDialog(title,
isPresented: $isPresented,
titleVisibility: .visible) {
ForEach(buttons, id: \.title) { button in
let role: ButtonRole? = button.role == .normal ? nil : button.role == .destructive ? .destructive : .cancel
Button(role: role, action: button.action) {
Text(button.title)
}
}
}
}
Then, Xcode hangs on build with the following activity:
Is there an error in my code or is this a compiler bug (Xcode Version 14.1 (14B47b))?
While your code is technically correct, the ability of view logic to evaluate variable values can get quite compiler-intensive, especially when you have multiple chained ternaries and logic inside a ForEach (which seems to make a bigger difference than one would probably think).
I'd be tempted to move the conditional logic outside the loop altogether, so that you're calling a method rather than needing to evaluate and store a local variable. You could make this a private func in your view, or as an extension to your custom enum. For example:
extension OptionButtonRole {
var buttonRole: ButtonRole? {
switch self {
case .destructive: return .destructive
case .cancel: return .cancel
default: return nil
}
}
}
// in your view
ForEach(buttons, id: \.title) { button in
Button(role: button.role.buttonRole, action: button.action) {
Text(button.title)
}
}
I have a state variable in an ObservedObject that determines which of two custom views I show in SwiftUI.
I've messed around with .animation(.easeIn) in various locations and tried things with .withAnimation(), but I can't get anything to happen besides XCode complaints while experimenting. Regardless of where I put .animation() I either get a compile error no nothing happens when I run the code. Just flick from one view to another when I trigger a state change.
struct EventEditorView : View { /* SwiftUI based View */
var eventEditorVC : EventEditorVC!
#ObservedObject var eventEditorDataModel: EventEditorDataModel
var body: some View {
switch( eventEditorDataModel.editMode) {
case .edit:
EventEditModeView(eventEditorVC: eventEditorVC, eventEditorDataModel: eventEditorDataModel)
case .view:
EventViewModeView(eventEditorVC: eventEditorVC, eventEditorDataModel: eventEditorDataModel)
}
}
}
You can use a .transition on your elements and withAnimation when you change the value that affects their state:
enum ViewToShow {
case one
case two
}
struct ContentView: View {
#State var viewToShow : ViewToShow = .one
var body: some View {
switch viewToShow {
case .one:
DetailView(title: "one", color: .red)
.transition(.opacity.combined(with: .move(edge: .leading)))
case .two:
DetailView(title: "two", color: .yellow)
.transition(.opacity.combined(with: .move(edge: .top)))
}
Button("Toggle") {
withAnimation {
viewToShow = viewToShow == .one ? .two : .one
}
}
}
}
struct DetailView : View {
var title: String
var color : Color
var body: some View {
Text(title)
.background(color)
}
}
I am trying to present a sequence of Views, each gathering some information from the user. When users enter all necessary data, they can move to next View. So far I have arrived at this (simplified) code, but I am unable to display the subview itself (see first line in MasterView VStack{}).
import SwiftUI
protocol DataEntry {
var entryComplete : Bool { get }
}
struct FirstSubView : View, DataEntry {
#State var entryComplete: Bool = false
var body: some View {
VStack{
Text("Gender")
Button("Male") {
entryComplete = true
}
Button("Female") {
entryComplete = true
}
}
}
}
struct SecondSubView : View, DataEntry {
var entryComplete: Bool {
return self.name != ""
}
#State private var name : String = ""
var body: some View {
Text("Age")
TextField("Your name", text: $name)
}
}
struct MasterView: View {
#State private var currentViewIndex = 0
let subview : [DataEntry] = [FirstSubView(), SecondSubView()]
var body: some View {
VStack{
//subview[currentViewIndex]
Text("Subview placeholder")
Spacer()
HStack {
Button("Prev"){
if currentViewIndex > 0 {
currentViewIndex -= 1
}
}.disabled(currentViewIndex == 0)
Spacer()
Button("Next"){
if (currentViewIndex < subview.count-1){
currentViewIndex += 1
}
}.disabled(!subview[currentViewIndex].entryComplete)
}
}
}
}
I do not want to use NavigationView for styling reasons. Can you please point me in the right direction how to solve this problem? Maybe a different approach?
One way to do this is with a Base View and a switch statement combined with an enum. This is a similar pattern I've used in the past to separate flows.
enum SubViewState {
case ViewOne, ViewTwo
}
The enum serves as a way to easily remember and track which views you have available.
struct BaseView: View {
#EnvironmentObject var subViewState: SubViewState = .ViewOne
var body: some View {
switch subViewState {
case ViewOne:
ViewOne()
case ViewTwo:
ViewTwo()
}
}
}
The base view is a Container for the view control. You will likely add a view model, which is recommended, and set the state value for your #EnvironmentObject or you'll get a null pointer exception. In this example I set it, but I'm not 100% sure if that syntax is correct as I don't have my IDE available.
struct SomeOtherView: View {
#EnvironmentObject var subViewState: SubViewState
var body: some View {
BaseView()
Button("Switch View") {
subViewState = .ViewTwo
}
}
}
This is just an example of using it. You can access your #EnvironmentObject from anywhere, even other views, as it's always available until disposed of. You can simply set a new value to it and it will update the BaseView() that is being shown here. You can use the same principle in your code, using logic, to determine the view to be shown and simply set its value and it will update.
SwiftUI seems cool, but some things just seem hard to me. Even so, I would rather understand how best to do something the SwiftUI way rather than wrap pre-swiftui controllers and do something the old way. So let me start with a simple problem -- displaying a web image given a URL. There are solutions, but they are not all that easy to find and not all the easy to understand.
I have a solution and would like some feedback. Below is an example of what I would like to do (the images is from Open Images).
struct ContentView: View {
#State var imagePath: String = "https://farm2.staticflickr.com/440/19711210125_6c12414d8f_o.jpg"
var body: some View {
WebImage(imagePath: $imagePath).scaledToFit()
}
}
My solution entails putting a little bit of code at the top of the body to start the image download. The image path has a #Binding property wrapper -- if it changes I want to update my view. There is also a myimage variable with a #State property wrapper -- when it gets set I also want to update my view. If everything goes well with the image load, myimage will be set and the an image displays. The initial problem is that changing the state within the body will result in the view being invalidated and trigger yet another download, ad infinitum. The solution seems simple (the code is below). Just check imagePath and see if it has changed since the last time something was loaded. Note that in download I set prev immediately, which triggers another execution of body. The conditional causes the state change to be ignored.
I read somewhere that #State checks for equality and will ignore sets if the value does not change. This kind of equality check will fail for UIImage. I expect three invocations of body: the initial invocation, the invocation when I set prev, and an invocation when I set image. I suppose I could add a mutable value for prev (i.e., a simple class) and avoid the second invocation.
Note that loading web content could have been accomplished using an extension and closures, but that's a different issue. Doing so, would have shrunk WebImage to just a few lines of code.
So, is there a better way to accomplish this task?
//
// ContentView.swift
// Learn
//
// Created by John Morris on 11/26/19.
// Copyright © 2019 John Morris. All rights reserved.
//
import SwiftUI
struct WebImage: View {
#Binding var imagePath: String?
#State var prev: String?
#State var myimage: UIImage?
#State var message: String?
var body: some View {
if imagePath != prev {
self.downloadImage(from: imagePath)
}
return VStack {
myimage.map({Image(uiImage: $0).resizable()})
message.map({Text("\($0)")})
}
}
init?(imagePath: Binding<String?>) {
guard imagePath.wrappedValue != nil else {
return nil
}
self._imagePath = imagePath
guard let _ = URL(string: self.imagePath!) else {
return nil
}
}
func getData(from url: URL, completion: #escaping (Data?, URLResponse?, Error?) -> ()) {
URLSession.shared.dataTask(with: url, completionHandler: completion).resume()
}
func downloadImage(from imagePath: String?) {
DispatchQueue.main.async() {
self.prev = imagePath
}
guard let imagePath = imagePath, let url = URL(string: imagePath) else {
self.message = "Image path is not URL"
return
}
getData(from: url) { data, response, error in
if let error = error {
self.message = error.localizedDescription
return
}
guard let httpResponse = response as? HTTPURLResponse else {
self.message = "No Response"
return
}
guard (200...299).contains(httpResponse.statusCode) else {
if httpResponse.statusCode == 404 {
self.message = "Page, \(url.absoluteURL), not found"
} else {
self.message = "HTTP Status Code \(httpResponse.statusCode)"
}
return
}
guard let mimeType = httpResponse.mimeType else {
self.message = "No mimetype"
return
}
guard mimeType == "image/jpeg" else {
self.message = "Wrong mimetype"
return
}
print(response.debugDescription)
guard let data = data else {
self.message = "No Data"
return
}
if let image = UIImage(data: data) {
DispatchQueue.main.async() {
self.myimage = image
}
}
}
}
}
struct ContentView: View {
var images = ["https://c1.staticflickr.com/3/2260/5744476392_5d025d6a6a_o.jpg",
"https://c1.staticflickr.com/9/8521/8685165984_e0fcc1dc07_o.jpg",
"https://farm1.staticflickr.com/204/507064030_0d0cbc850c_o.jpg",
"https://farm2.staticflickr.com/440/19711210125_6c12414d8f_o.jpg"
]
#State var imageURL: String?
#State var count = 0
var body: some View {
VStack {
WebImage(imagePath: $imageURL).scaledToFit()
Button(action: {
self.imageURL = self.images[self.count]
self.count += 1
if self.count >= self.images.count {
self.count = 0
}
}) {
Text("Next")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I would suggest two things. First, you generally want to allow a placeholder View for when the image is downloading. Second, you should cache the image otherwise if you have something like a tableView where it scrolls off screen and back on screen, you are going to keep downloading the image over an over again. Here is an example from one of my apps of how I addressed it:
import SwiftUI
import Combine
import UIKit
class ImageCache {
enum Error: Swift.Error {
case dataConversionFailed
case sessionError(Swift.Error)
}
static let shared = ImageCache()
private let cache = NSCache<NSURL, UIImage>()
private init() { }
static func image(for url: URL) -> AnyPublisher<UIImage?, ImageCache.Error> {
guard let image = shared.cache.object(forKey: url as NSURL) else {
return URLSession
.shared
.dataTaskPublisher(for: url)
.tryMap { (tuple) -> UIImage in
let (data, _) = tuple
guard let image = UIImage(data: data) else {
throw Error.dataConversionFailed
}
shared.cache.setObject(image, forKey: url as NSURL)
return image
}
.mapError({ error in Error.sessionError(error) })
.eraseToAnyPublisher()
}
return Just(image)
.mapError({ _ in fatalError() })
.eraseToAnyPublisher()
}
}
class ImageModel: ObservableObject {
#Published var image: UIImage? = nil
var cacheSubscription: AnyCancellable?
init(url: URL) {
cacheSubscription = ImageCache
.image(for: url)
.replaceError(with: nil)
.receive(on: RunLoop.main, options: .none)
.assign(to: \.image, on: self)
}
}
struct RemoteImage : View {
#ObservedObject var imageModel: ImageModel
init(url: URL) {
imageModel = ImageModel(url: url)
}
var body: some View {
imageModel
.image
.map { Image(uiImage:$0).resizable() }
?? Image(systemName: "questionmark").resizable()
}
}