DocumentGroup is sometimes creating multiple views - swiftui

In my application, I'm creating an object and adding data to it with an .onAppear. I've noticed sometimes (1 in 4 to 6 times), my object is being created multiple times, and the .onAppear is not being called for each.
After much trial and error, I reduced the code to the following.
I added an init to the ContentView to print when it is being called and in the read for the TextDocument. Most of the time when I open a text file, there is one "Read data" and one "Create view". Every 3 to 6 times, there is one "Read data" and three "Create view. The size of the text file does not change the frequency of the error.
I tried the same experiment with a WindowView and never saw multiple "Create view" messages.
import SwiftUI
import UniformTypeIdentifiers
#main
struct OneMoreTimeApp: App {
var body: some Scene {
DocumentGroup(newDocument: TextDocument()) { file in
ContentView(document: file.$document)
}
}
}
struct ContentView: View {
#Binding var document: TextDocument
init(document: Binding<TextDocument>) {
print("Create view")
self._document = document
}
var body: some View {
VStack {
Text("Hello World")
Text(document.data)
}
}
}
struct TextDocument: FileDocument {
static var readableContentTypes : [UTType] {[.text]}
var data: String
init(text: String = "This is some text") {
print("init data")
self.data = text
}
init(configuration: ReadConfiguration) throws {
print("read data")
if let data = configuration.file.regularFileContents {
self.data = String(decoding: data, as: UTF8.self)
} else {
throw CocoaError(.fileReadCorruptFile)
}
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
print("write data")
let outData = Data(data.utf8)
return FileWrapper(regularFileWithContents: outData)
}
}
I'm thinking this is an Apple bug, but want it to run it by here first incase I'm doing something wrong.
Test Case
Launch the application
Open a text file
Wait ten seconds
Verify that the create view message is only printed once
Close the text file
Repeat steps 1 through 5 ten time.
It usually fails within 5 try.
I can also reproduce the issue by skipping step 5 and repeating steps 2 through 4, but it takes long to reproduce, up to 30 times.
I reproduced the problem on an iPad Pro (12.9-inch) )3rd gen) running 16.2
I reproduced the problem on an iPhone 14 Pro Max running 16.2
I reproduced the problem on an iPhone 14 simulator
I built the app with a target of 16.1, 16.0, 15.6 and 14.7. All shows the same problem.
Running Xcode 14.2
Here is a less trivial version that creates an object, which gets created multiple times.
import SwiftUI
import UniformTypeIdentifiers
#main
struct OneMoreTimeApp: App {
var body: some Scene {
DocumentGroup(newDocument: TextDocument()) { file in
ContentView(document: file.$document)
}
}
}
struct ContentView: View {
#Binding var document: TextDocument
#State var myObject = MyObject()
init(document: Binding<TextDocument>) {
print("Create view")
self._document = document
}
var body: some View {
VStack {
Text("Hello World")
Text("\(myObject.text)")
Text(document.data)
.onAppear{
print("onAppear - myObject is \(myObject.text)")
myObject.text += " - onAppear"
}
}
}
}
struct TextDocument: FileDocument {
static var readableContentTypes : [UTType] {[.text]}
var data: String
init(text: String = "This is some text") {
print("init data")
self.data = text
}
init(configuration: ReadConfiguration) throws {
print("read data")
if let data = configuration.file.regularFileContents {
self.data = String(decoding: data, as: UTF8.self)
} else {
throw CocoaError(.fileReadCorruptFile)
}
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
print("write data")
let outData = Data(data.utf8)
return FileWrapper(regularFileWithContents: outData)
}
}
class MyObject : ObservableObject {
static var numberOfObjects = 0
#Published var text = "none"
init() {
MyObject.numberOfObjects += 1
text = "Object number \(MyObject.numberOfObjects)"
print("created \(text)")
}
deinit {
print("killed \(text)")
}
}

Whatever work you're doing in ContentView.init that you don't want repeated, you should move into an ObservableObject and store in a #StateObject-wrapped property instead.

Rob pointed me down a path that led to this slightly more complicated code. In it I create three copies of the object, one as a #state, one a StateObject and one as a #ObservedObjects.
The code compiles and generally works with all three. In the case where the View is created multiple times all three act differently.
The #State gets destroyed and recreated, but behind the scenes the old data is copied into the new object.
The #StateObject does not get destroyed.
The #ObservableObject is destroyed and left in its initialized state (the old data does not get copied into it.)
I the failing case I get the following prints:
read data
created Object0 number 1
created Object2 number 2
Create view
created Object1 number 3
onAppear
onChange - size is (1366.0, 878.0)
created Object0 number 4
created Object2 number 5
Create view
killed Object2 number 2 - onAppear
created Object0 number 6
created Object2 number 7
Create view
killed Object0 number 4
killed Object2 number 5
On the screen it shows:
Hello World
object 0 1,366.000000 text: Object0 number 1 - onAppear
object 1 1,366.000000 text: Object1 number 3 - onAppear
object 2 1,366.000000 text: Object7 number 7
CGFloat 1,366.000000
So, I had a bug in my code, I was using #ObservedObject when I should have been using #StateObject.
However, it still seems strange that view is getting created extra times and data is being copied from the old one to the new one (#State vars).
What do you guys think? should I report this to Apple or figure since there is code that does the copying when it happens it must be right?
import SwiftUI
import UniformTypeIdentifiers
#main
struct OneMoreTimeApp: App {
var body: some Scene {
DocumentGroup(newDocument: TextDocument()) { file in
ContentView(document: file.$document)
}
}
}
struct ContentView: View {
#Binding var document: TextDocument
#State var myObject0 = MyObject(0)
#StateObject var myObject1 = MyObject(1)
#ObservedObject var myObject2 = MyObject(2) // does not work
#State var theWidth : CGFloat = 0
init(document: Binding<TextDocument>) {
print("Create view")
self._document = document
}
var body: some View {
VStack {
GeometryReader { geomerty in
VStack {
Rectangle()
.frame(height: 50)
.frame(maxWidth: .infinity)
Text("Hello World")
Text("object 0 \(myObject0.width) text:\(myObject0.text)")
Text("object 1 \(myObject1.width) text:\(myObject1.text)")
Text("object 2 \(myObject2.width) text:\(myObject2.text)")
Text("CGFloat \(theWidth)")
Text(document.data)
.onAppear{
print("onAppear")
myObject0.text += " - onAppear"
myObject1.text += " - onAppear"
myObject2.text += " - onAppear"
}
.onChange(of: geomerty.size) { size in
print("onChange - size is \(geomerty.size)")
myObject0.width = size.width
myObject1.width = size.width
myObject2.width = size.width
theWidth = size.width
}
Button("set to 42") {
myObject0.width = 42.0
myObject1.width = 42.0
myObject2.width = 42.0
theWidth = 42.0
}
}
}
}
}
}
struct TextDocument: FileDocument {
static var readableContentTypes : [UTType] {[.text]}
var data: String
init(text: String = "This is some text") {
print("init data")
self.data = text
}
init(configuration: ReadConfiguration) throws {
print("read data")
if let data = configuration.file.regularFileContents {
self.data = String(decoding: data, as: UTF8.self)
} else {
throw CocoaError(.fileReadCorruptFile)
}
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
print("write data")
let outData = Data(data.utf8)
return FileWrapper(regularFileWithContents: outData)
}
}
class MyObject : ObservableObject {
static var numberOfObjects = 0
#Published var text = "none"
#Published var width : CGFloat = 0.0
init(_ i:Int) {
MyObject.numberOfObjects += 1
text = "Object\(i) number \(MyObject.numberOfObjects)"
print("created \(text)")
}
deinit {
print("killed \(text)")
}
}

Related

Making data persist in Swift

I'm sorry if this is a naive question, but I need help getting this form to persist in core data. The variables are declared in the data model as strings. I simply cannot get this to cooperate with me. Also, the var wisconsin: String = "" is there because I can't call this view in my NavigationView without it throwing an error.
import SwiftUI
struct WisconsinToolOld: View {
//Variable
var wisconsin: String = ""
#Environment(\.managedObjectContext) private var viewContext
#State var saveInterval: Int = 5
var rateOptions = ["<12", ">12"]
#State var rate = ""
var body: some View {
List {
Section(header: Text("Spontaneous Respirations after 10 Minutes")) {
HStack {
Text("Respiratory Rate")
Spacer()
Picker("Rate", selection: $rate, content: {
ForEach(rateOptions, id: \.self, content: { rate in
Text(rate)
})
})
.pickerStyle(.segmented)
}
Section(header: Text("Result")) {
HStack {
Text("Raw Points")
Spacer()
Text("\(WisconsinToolInterpretation())")
}
}.navigationTitle("Wisconsin Tool")
}
}
func saveTool() {
do {
let wisconsin = Wisconsin(context: viewContext)
wisconsin.rate = rate
try viewContext.save()
} catch {
print(error.localizedDescription)
}
}
func WisconsinToolInterpretation() -> Int {
var points = 0
if rate == "<12" {
points += 3
}
else {
points += 1
}
return points
}
}

Delay in SwiftUI view appearing

I have a view that displays two calculated strings. At present, I calculate the strings with .onAppear. But the view does not render until the strings are calculated, leaving the user watching the previous view for 2 to 5 seconds till the calculation is done, and the progress bar never gets shown.
The code is:
struct CalculatingProgressView: View {
var body: some View {
ProgressView {
Text("Calculating")
.font(.title)
}
}
}
struct OffspringView: View {
#State private var males: String = ""
#State private var females: String = ""
#State private var busy = true
func determineOffspring() {
let temp = theOffspring(of: sire, and: dam)
males = temp.0
females = temp.1
busy = false
}
var body: some View {
Section(header: Text("Male Offspring")) {
Text(males)
.font(.callout)
}
if busy {
CalculatingProgressView()
}
Section(header: Text("Female Offspring")) {
Text(females)
.font(.callout)
}
.onAppear { determineOffspring() }
}
}
How can I get the view to render with a progress bar so the user knows that the app is actually doing something?
your code seems to work for me. You could try this approach,
to show the CalculatingProgressView while it's calculating determineOffspring.
var body: some View {
if busy {
CalculatingProgressView()
.onAppear { determineOffspring() }
} else {
Section(header: Text("Male Offspring")) {
Text(males).font(.callout)
}
Section(header: Text("Female Offspring")) {
Text(females).font(.callout)
}
}
}
}
Note, your theOffspring(...) in determineOffspring should use a completion closure something like
the following, to "wait" until the calculations are finished:
func determineOffspring() {
theOffspring(of: sire, and: dam) { result in
males = result.0
females = result.1
busy = false
}
}

SwifUI onAppear gets called twice

Q1: Why are onAppears called twice?
Q2: Alternatively, where can I make my network call?
I have placed onAppears at a few different place in my code and they are all called twice. Ultimately, I'm trying to make a network call before displaying the next view so if you know of a way to do that without using onAppear, I'm all ears.
I have also tried to place and remove a ForEach inside my Lists and it doesn't change anything.
Xcode 12 Beta 3 -> Target iOs 14
CoreData enabled but not used yet
struct ChannelListView: View {
#EnvironmentObject var channelStore: ChannelStore
#State private var searchText = ""
#ObservedObject private var networking = Networking()
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText)
.padding(.top, 20)
List() {
ForEach(channelStore.allChannels) { channel in
NavigationLink(destination: VideoListView(channel: channel)
.onAppear(perform: {
print("PREVIOUS VIEW ON APPEAR")
})) {
ChannelRowView(channel: channel)
}
}
.listStyle(GroupedListStyle())
}
.navigationTitle("Channels")
}
}
}
}
struct VideoListView: View {
#EnvironmentObject var videoStore: VideoStore
#EnvironmentObject var channelStore: ChannelStore
#ObservedObject private var networking = Networking()
var channel: Channel
var body: some View {
List(videoStore.allVideos) { video in
VideoRowView(video: video)
}
.onAppear(perform: {
print("LIST ON APPEAR")
})
.navigationTitle("Videos")
.navigationBarItems(trailing: Button(action: {
networking.getTopVideos(channelID: channel.channelId) { (videos) in
var videoIdArray = [String]()
videoStore.allVideos = videos
for video in videoStore.allVideos {
videoIdArray.append(video.videoID)
}
for (index, var video) in videoStore.allVideos.enumerated() {
networking.getViewCount(videoID: videoIdArray[index]) { (viewCount) in
video.viewCount = viewCount
videoStore.allVideos[index] = video
networking.setVideoThumbnail(video: video) { (image) in
video.thumbnailImage = image
videoStore.allVideos[index] = video
}
}
}
}
}) {
Text("Button")
})
.onAppear(perform: {
print("BOTTOM ON APPEAR")
})
}
}
I had the same exact issue.
What I did was the following:
struct ContentView: View {
#State var didAppear = false
#State var appearCount = 0
var body: some View {
Text("Appeared Count: \(appearrCount)"
.onAppear(perform: onLoad)
}
func onLoad() {
if !didAppear {
appearCount += 1
//This is where I loaded my coreData information into normal arrays
}
didAppear = true
}
}
This solves it by making sure only what's inside the the if conditional inside of onLoad() will run once.
Update: Someone on the Apple Developer forums has filed a ticket and Apple is aware of the issue. My solution is a temporary hack until Apple addresses the problem.
I've been using something like this
import SwiftUI
struct OnFirstAppearModifier: ViewModifier {
let perform:() -> Void
#State private var firstTime: Bool = true
func body(content: Content) -> some View {
content
.onAppear{
if firstTime{
firstTime = false
self.perform()
}
}
}
}
extension View {
func onFirstAppear( perform: #escaping () -> Void ) -> some View {
return self.modifier(OnFirstAppearModifier(perform: perform))
}
}
and I use it instead of .onAppear()
.onFirstAppear{
self.vm.fetchData()
}
you can create a bool variable to check if first appear
struct VideoListView: View {
#State var firstAppear: Bool = true
var body: some View {
List {
Text("")
}
.onAppear(perform: {
if !self.firstAppear { return }
print("BOTTOM ON APPEAR")
self.firstAppear = false
})
}
}
Let us assume you are now designing a SwiftUI and your PM is also a physicist and philosopher. One day he tells you we should to unify UIView and UIViewController, like Quantum Mechanics and the Theory of Relativity. OK, you are like-minded with your leader, voting for "Simplicity is Tao", and create an atom named "View". Now you say: "View is everything, view is all". That sounds awesome and seems feasible. Well, you commit the code and tell the PM….
onAppear and onDisAppear exists in every view, but what you really need is a Page lifecycle callback. If you use onAppear like viewDidAppear, then you get two problems:
Being influenced by the parent, the child view will rebuild more than one time, causing onAppear to be called many times.
SwiftUI is closed source, but you should know this: view = f(view). So, onAppear will run to return a new View, which is why onAppear is called twice.
I want to tell you onAppear is right! You MUST CHANGE YOUR IDEAS. Don’t run lifecycle code in onAppear and onDisAppear! You should run that code in the "Behavior area". For example, in a button navigating to a new page.
You can create the first appear function for this bug
extension View {
/// Fix the SwiftUI bug for onAppear twice in subviews
/// - Parameters:
/// - perform: perform the action when appear
func onFirstAppear(perform: #escaping () -> Void) -> some View {
let kAppearAction = "appear_action"
let queue = OperationQueue.main
let delayOperation = BlockOperation {
Thread.sleep(forTimeInterval: 0.001)
}
let appearOperation = BlockOperation {
perform()
}
appearOperation.name = kAppearAction
appearOperation.addDependency(delayOperation)
return onAppear {
if !delayOperation.isFinished, !delayOperation.isExecuting {
queue.addOperation(delayOperation)
}
if !appearOperation.isFinished, !appearOperation.isExecuting {
queue.addOperation(appearOperation)
}
}
.onDisappear {
queue.operations
.first { $0.name == kAppearAction }?
.cancel()
}
}
}
For everyone still having this issue and using a NavigationView. Add this line to the root NavigationView() and it should fix the problem.
.navigationViewStyle(StackNavigationViewStyle())
From everything I have tried, this is the only thing that worked.
We don't have to do it on .onAppear(perform)
This can be done on init of View
In case someone else is in my boat, here is how I solved it for now:
struct ChannelListView: View {
#State private var searchText = ""
#State private var isNavLinkActive: Bool = false
#EnvironmentObject var channelStore: ChannelStore
#ObservedObject private var networking = Networking()
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText)
.padding(.top, 20)
List(channelStore.allChannels) { channel in
ZStack {
NavigationLink(destination: VideoListView(channel: channel)) {
ChannelRowView(channel: channel)
}
HStack {
Spacer()
Button {
isNavLinkActive = true
// Place action/network call here
} label: {
Image(systemName: "arrow.right")
}
.foregroundColor(.gray)
}
}
.listStyle(GroupedListStyle())
}
.navigationTitle("Channels")
}
}
}
}
I've got this app:
#main
struct StoriesApp: App {
var body: some Scene {
WindowGroup {
TabView {
NavigationView {
StoriesView()
}
}
}
}
}
And here is my StoriesView:
// ISSUE
struct StoriesView: View {
#State var items: [Int] = []
var body: some View {
List {
ForEach(items, id: \.self) { id in
StoryCellView(id: id)
}
}
.onAppear(perform: onAppear)
}
private func onAppear() {
///////////////////////////////////
// Gets called 2 times on app start <--------
///////////////////////////////////
}
}
I've resolved the issue by measuring the diff time between onAppear() calls. According to my observations double calls of onAppear() happen between 0.02 and 0.45 seconds:
// SOLUTION
struct StoriesView: View {
#State var items: [Int] = []
#State private var didAppearTimeInterval: TimeInterval = 0
var body: some View {
List {
ForEach(items, id: \.self) { id in
StoryCellView(id: id)
}
}
.onAppear(perform: onAppear)
}
private func onAppear() {
if Date().timeIntervalSince1970 - didAppearTimeInterval > 0.5 {
///////////////////////////////////////
// Gets called only once in 0.5 seconds <-----------
///////////////////////////////////////
}
didAppearTimeInterval = Date().timeIntervalSince1970
}
}
In my case, I found that a few views up the hierarchy, .onAppear() (and .onDisappear()) was only being called once, as expected. I used that to post notifications that I listen to down in the views that need to take action on those events. It’s a gross hack, and I’ve verified that the bug is fixed in iOS 15b1, but Apple really needs to backport the fix.

Why does a SwiftUI TextField inside a navigation bar only accept input one character at a time

I want to allow the user to filter data in a long list to more easily find matching titles.
I have placed a TextView inside my navigation bar:
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: TextField("search", text: $modelData.searchString)
I have an observable object which responds to changes in the search string:
class DataModel: ObservableObject {
#Published var modelData: [PDFSummary]
#Published var searchString = "" {
didSet {
if searchString == "" {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name })
} else {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name }).filter({ $0.name.lowercased().contains(searchString.lowercased()) })
}
}
}
Everything works fine, except I have to tap on the field after entering each letter. For some reason the focus is taken away from the field after each letter is entered (unless I tap on a suggested autocorrect - the whole string is correctly added to the string at once)
The problem is in rebuilt NavigationView completely that result in dropped text field focus.
Here is working approach. Tested with Xcode 11.4 / iOS 13.4
The idea is to avoid rebuild NavigationView based on knowledge that SwiftUI engine updates only modified views, so using decomposition we make modifications local and transfer desired values only between subviews directly not affecting top NavigationView, as a result the last kept stand.
class QueryModel: ObservableObject {
#Published var query: String = ""
}
struct ContentView: View {
// No QueryModel environment object here -
// implicitly passed down. !!! MUST !!!
var body: some View {
NavigationView {
ResultsView()
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: SearchItem())
}
}
}
struct ResultsView: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
var body: some View {
VStack {
Text("Search: \(qm.query)") // receive query string
}
}
}
struct SearchItem: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
#State private var query = "" // updates only local view
var body: some View {
let text = Binding(get: { self.query }, set: {
self.query = $0; self.qm.query = $0; // transfer query string
})
return TextField("search", text: text)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(QueryModel())
}
}

SwiftUI: .sheet() doesn't go to the previous view with expected data when dismiss current sheet

As minimal, my code is like below. In SinglePersonView When user tap one image of movie in MovieListView(a movie list showing actor attended movies), then it opens the SingleMovieView as sheet mode.
The sheet could be popped up as tapping. But I found after close the sheet and re-select other movie in MovieListView, the sheet always opened as my previous clicked movie info aka the first time chosen one. And I could see in console, the movie id is always the same one as the first time. I get no clues now, do I need some reloading operation on the dismissal or something else?
And is it the correct way to use .sheet() in subView in SwiftUI, or should always keep it in the main body, SinglePersonView in this case.
struct SinglePersonView: View {
var personId = -1
#ObservedObject var model = MovieListViewModel()
var body: some View {
ScrollView() {
VStack() {
...
MovieListView(movies: model.movies)
...
}
}.onAppear {
// json API request
}
}
}
struct MovieListView: View {
var movies: [PersonMovieViewModel]
#State private var showSheet = false
ScrollView() {
HStack() {
ForEach(movies) { movie in
VStack() {
Image(...)
.onTapGesture {
self.showSheet.toggle()
}
.sheet(isPresented: self.$showSheet) {
SingleMovieView(movieId: movie.id)
}
}
}
}
}
}
There should be only one .sheet in view stack, but in provided snapshot there are many which activated all at once - following behaviour is unpredictable, actually.
Here is corrected variant
struct MovieListView: View {
var movies: [PersonMovieViewModel]
#State private var showSheet = false
#State private var selectedID = "" // type of your movie's ID
var body: some View {
ScrollView() {
HStack() {
ForEach(movies) { movie in
VStack() {
Image(...)
.onTapGesture {
self.selectedID = movie.id
self.showSheet.toggle()
}
}
}
}
.sheet(isPresented: $showSheet) {
SingleMovieView(movieId: selectedID)
}
}
}
}