Using Swift5.2.3, iOS14.4.2, XCode12.4,
Working with the .sheet modifier in SwiftUI made me feel excited at first since it seemed like an easy and efficient way to display a modal sheet.
However, inside a real-world application it turns out that .sheet is all but ready for integration.
Here are two bugs found:
Bug 1: The sheet does not close sporadically
Bug 2: The Picker with DefaultPickerStyle does not work when inside a sheet's SegmentPicker (See this Stackoverlow-question that I created)
Let's focus now on Bug Nr1 : "sheet does not close":
The cmd presentationMode.wrappedValue.dismiss() is supposed to close a sheet. It works 90% of the cases. But every so often and without giving a hin on its reasons, the modal-sheet does not close.
Here is a code-excerpt:
import SwiftUI
import Firebase
struct MyView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Form {
Section(header: Text("Login")) {
Button(action: {
UserDefaults.standard.set(true, forKey: AppConstants.UserDefaultKeys.justLogoutLoginPressed)
try? Auth.auth().signOut()
// supposedly should work all the time - but it only works 90% of the time.....
presentationMode.wrappedValue.dismiss()
}) {
HStack {
Text((Auth.auth().currentUser?.isAnonymous ?? true) ? "Login" : "Logout")
Spacer()
}
}
}
}
.ignoresSafeArea()
Spacer()
}
}
}
I also tried to wrap the closing call inside the main-thread:
DispatchQueue.main.async {
presentationMode.wrappedValue.dismiss()
}
But it did not help.
Any idea why SwiftUI .sheets would not close using the presentationMode to dismiss it ??
Here I added the way the sheet is called in the first place. Since taken out of a bigger App, I obviously only show an example here on how the sheet is called:
import SwiftUI
#main
struct TestKOS005App: App {
#StateObject var appStateService = AppStateService(appState: .startup)
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(appStateService)
}
}
}
class AppStateService: ObservableObject {
#Published var appState: THAppState
var cancellableSet = Set<AnyCancellable>()
init(appState: THAppState) {
self.appState = appState
}
// ...
}
enum THAppState: Equatable {
case startup
case downloading
case caching
case waiting
case content(tagID: String, name: String)
case cleanup
}
struct MainView: View {
#EnvironmentObject var appStateService: AppStateService
#State var sheetState: THSheetSelection?
init() {
UINavigationBar.appearance().tintColor = UIColor(named: "title")
}
var body: some View {
ZStack {
NavigationView {
ZStack {
switch appStateService.appState {
case .caching:
Text("caching")
case .waiting:
Text("waiting")
case .content(_, _):
VStack {
Text("content")
Button(action: {
sheetState = .sheetType3
}, label: {
Text("Button")
})
}
default:
Text("no screen")
}
}
.sheet(item: $sheetState) { state in
switch state {
case .sheetType1:
Text("sheetType1")
case .sheetType2:
Text("sheetType2")
case .sheetType3:
MyView()
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
enum THSheetSelection: Hashable, Identifiable {
case sheetType1
case sheetType2
case sheetType3
var id: THSheetSelection { self }
}
I think when signing out, you probably have an instance checking whether Firebase Auth has an active user session and changes the view to the login screen when you call try? Auth.auth().signOut() and it might prevent the presentationMode.wrappedValue.dismiss() is being called.
You might want to create a state property in MainView and a corresponding Binding property in MyView and manage the state of signing out with them like follows.
In the MyView; instead of calling signout() directly;
struct MyView: View {
#Environment(\.presentationMode) var presentationMode
#Binding var logoutTapped: Bool
var body: some View {
VStack {
Form {
Section(header: Text("Login")) {
Button(action: {
UserDefaults.standard.set(true, forKey: AppConstants.UserDefaultKeys.justLogoutLoginPressed)
// try? Auth.auth().signOut() -> instead of this directly
logoutTapped = true // call this
// supposedly should work all the time - but it only works 90% of the time.....
presentationMode.wrappedValue.dismiss()
}) {
HStack {
Text((Auth.auth().currentUser?.isAnonymous ?? true) ? "Login" : "Logout")
Spacer()
}
}
}
}
.ignoresSafeArea()
Spacer()
}
}
}
and in the MainView, when creating sheet, in onDismissal block, set a condition on logoutTapped bool state, and logout there like below;
struct MainView: View {
#EnvironmentObject var appStateService: AppStateService
#State var sheetState: THSheetSelection?
#State var logoutTapped = false
init() {
UINavigationBar.appearance().tintColor = UIColor(named: "title")
}
var body: some View {
ZStack {
NavigationView {
ZStack {
switch appStateService.appState {
case .caching:
Text("caching")
case .waiting:
Text("waiting")
case .content(_, _):
VStack {
Text("content")
Button(action: {
sheetState = .sheetType3
}, label: {
Text("Button")
})
}
default:
Text("no screen")
}
}
.sheet(item: $sheetState) {
if logoutTapped { // if this is true call signout
Auth.auth().signout()
}
} content: { state in
switch state {
case .sheetType1:
Text("sheetType1")
case .sheetType2:
Text("sheetType2")
case .sheetType3:
MyView(logoutTapped: $logoutTapped) // send logoutTapped to MyView
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
I have noticed that when I'm coloring the background I will not get animations when removing Views.
If I remove Color(.orange).edgesIgnoringSafeArea(.all) then hide animation will work, otherwise Modal will disappear abruptly. Any solutions?
struct ContentView: View {
#State var show = false
func toggle() {
withAnimation {
show = true
}
}
var body: some View {
ZStack {
Color(.orange).edgesIgnoringSafeArea(.all)
Button(action: toggle) {
Text("Modal")
}
if show {
Modal(show: $show)
}
}
}
}
struct Modal: View {
#Binding var show: Bool
func toggle() {
withAnimation {
show = false
}
}
var body: some View {
ZStack {
Color(.systemGray4).edgesIgnoringSafeArea(.all)
Button(action: toggle) {
Text("Close")
}
}
}
}
You need to make animatable container holding removed view (and this makes possible to keep animation in one place). Here is possible solution.
Tested with Xcode 12 / iOS 14
struct ContentView: View {
#State var show = false
func toggle() {
show = true // animation not requried
}
var body: some View {
ZStack {
Color(.orange).edgesIgnoringSafeArea(.all)
Button(action: toggle) {
Text("Modal")
}
VStack { // << major changes
if show {
Modal(show: $show)
}
}.animation(.default) // << !!
}
}
}
struct Modal: View {
#Binding var show: Bool
func toggle() {
show = false // animation not requried
}
var body: some View {
ZStack {
Color(.systemGray4).edgesIgnoringSafeArea(.all)
Button(action: toggle) {
Text("Close")
}
}
}
}
I have a sign out button on a modal sheet that takes the user back to the login screen. To accomplish this I first dismiss the sheet and then, using asyncAfter(deadline:) I set an environment variable that causes the login page to appear. Everything works fine, but once the sheet is dismissed, the transition from the view under the sheet to the login page is pretty jarring. Mostly because there isn't one. The top view just disappears, revealing the login view. I know I can create custom transitions, but I can't figure out where to attach it. Say, for example, I want to fade out the view underneath the sheet. (Although, I'm open to any kind of transition!)
This is the struct that directs the traffic:
struct ConductorView: View {
#EnvironmentObject var tower: Tower
let onboardingCompleted = UserDefaults.standard.bool(forKey: "FirstVisit")
var body: some View {
VStack {
if tower.currentPage == .onboarding {
Onboarding1View()
} else if tower.currentPage == .login {
LoginView()
} else if tower.currentPage == .idle {
LoginView()
}
}.onAppear{
if self.onboardingCompleted {
self.tower.currentPage = .login
} else {
self.tower.currentPage = .onboarding
}
}
}
}
And this is the sign out button on the sheet:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.tower.currentPage = .login
}
}) {
Text("Sign Out")
}
Here is a simplified demo on your replicated code (and I made some longer delay to make it mode visible). Of course you will need to tune it for your needs by changing type of transition or animation, etc. Tested with Xcode 12 / iOS 14
class Tower: ObservableObject {
enum PageType {
case onboarding, login, idle
}
#Published var currentPage: PageType = .onboarding
}
struct ConductorView: View {
#EnvironmentObject var tower: Tower
let onboardingCompleted = false
var body: some View {
VStack {
if tower.currentPage == .onboarding {
Onboarding1View()
} else if tower.currentPage == .login {
Text("LoginView")
.transition(.move(edge: .trailing)) // << here !!
} else if tower.currentPage == .idle {
Text("IdleView")
}
}
.animation(.default, value: tower.currentPage) // << here !!
.onAppear{
if self.onboardingCompleted {
self.tower.currentPage = .login
} else {
self.tower.currentPage = .onboarding
}
}
}
}
struct Onboarding1View: View {
#EnvironmentObject var tower: Tower
#Environment(\.presentationMode) var presentationMode
#State private var isPresented = true
var body: some View {
Text("Login")
.sheet(isPresented: $isPresented) {
Button(action: {
self.presentationMode.wrappedValue.dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.tower.currentPage = .login
}
}) {
Text("Sign Out")
}
}
}
}
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.
I have an app where I would like the user to be able to start a timed task, and when time runs out, the navigation hierarchy should pop and bring the user back. I have code that ~works, but I don't like the code smell. Is this the right way to approach something like this?
class SimpleTimerManager: ObservableObject {
#Published var elapsedSeconds: Double = 0.0
private(set) var timer = Timer()
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) {_ in
self.elapsedSeconds += 0.01
}
}
func stop() {
timer.invalidate()
elapsedSeconds = 0.0
}
}
struct ContentView: View {
#ObservedObject var timerManager = SimpleTimerManager()
var body: some View {
NavigationView {
NavigationLink(destination: CountDownIntervalView(
timerManager: timerManager, length: 5.0
)) {
Text("Start the timer!")
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct CountDownIntervalView: View {
#ObservedObject var timerManager: SimpleTimerManager
var length: Double
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var interval: Double {
let interval = length - self.timerManager.elapsedSeconds
if interval <= 0 {
self.mode.wrappedValue.dismiss()
self.timerManager.stop()
}
return interval
}
var body: some View {
VStack {
Text("Time remaining: \(String(format: "%.2f", interval))")
Button(action: {
self.mode.wrappedValue.dismiss()
self.timerManager.stop()
}) {
Text("Quit early!")
}
}
.navigationBarBackButtonHidden(true)
.onAppear(perform: {
self.timerManager.start()
})
}
}
I have a custom back button, which is something I'd like to preserve. My main concern is that it feels wrong to have code that stops the timer and pops the navigation inside a computed property. I'd prefer something like
Text("\(interval)").onReceive(timerManager.timer, perform: { _ in
if self.interval <= 0 {
self.mode.wrappedValue.dismiss()
self.timerManager.stop()
}
})
inside CountDownIntervalView, but this generates a compiler error - Unable to infer complex closure return type; add explicit type to disambiguate - and, to be honest, I'm not sure that approach makes sense either (attaching the code that conditionally pops the navigation to a piece of UI). What is the "best practices" way of approaching this problem?
Thanks for any thoughts.
Here is a solution. Tested with Xcode 11.4 / iOS 13.4
struct CountDownIntervalView: View {
#ObservedObject var timerManager: SimpleTimerManager
var length: Double
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var interval: Double {
length - self.timerManager.elapsedSeconds
}
var body: some View {
VStack {
Text("Time remaining: \(String(format: "%.2f", interval))")
.onReceive(timerManager.$elapsedSeconds) { _ in
if self.interval <= 0 {
self.mode.wrappedValue.dismiss()
self.timerManager.stop()
}
}
// ... other your code