I'm trying to execute some code I'd have previously put in my app delegate, such as saving my managed object context when entering the background. I put the call in the .onChange for the scenePhase, but I'm not getting anything. Here's a sample project:
import SwiftUI
#main
struct PhaseApp: App {
#Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
Text("Hello, world.")
}
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
print("Active")
case .background:
print("Background")
case .inactive:
print("Inactive")
#unknown default: break
}
}
}
}
I'd expect to get a print command in the Simulator or on my test device whenever I press Home or tap the app, but nothing happens.
I acknowledge this question is specifically about schenePhase changes, however, on macOS I am not able to receive any .background notifications when a user switches to a different app. The older NotificationCenter strategy works as I expected, on both platforms. I'll add this to the mix for anyone who is just trying to execute some code, onForeground / onBackground on iOS and macOS.
On any view, you can attach:
.onReceive(NotificationCenter.default.publisher(for: .willResignActiveNotification)) { _ in
doBackgroundThing()
}
The events you may care about are:
iOS: willResignActiveNotification & willEnterForegroundNotification
macOS: willResignActiveNotification & willBecomeActiveNotification
You can find all NotificationCenter Names here.
I use will* variants for background because I assume they'll be called early in the process, and I use did* variants for foreground, because they are called regardless of whether the app is launched for the first time, or it's coming out of background.
I use this extension so I don't have to think about the platform differences:
extension View {
#if os(iOS)
func onBackground(_ f: #escaping () -> Void) -> some View {
self.onReceive(
NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification),
perform: { _ in f() }
)
}
func onForeground(_ f: #escaping () -> Void) -> some View {
self.onReceive(
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification),
perform: { _ in f() }
)
}
#else
func onBackground(_ f: #escaping () -> Void) -> some View {
self.onReceive(
NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification),
perform: { _ in f() }
)
}
func onForeground(_ f: #escaping () -> Void) -> some View {
self.onReceive(
NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification),
perform: { _ in f() }
)
}
#endif
}
As expected, I use it as such:
AppView()
.onBackground {
print("my background")
}
.onForeground {
print("my foreground")
}
Use inside scene root view (usually ContentView)
Tested with Xcode 12 / iOS 14 as worked.
struct ContentView: View {
#Environment(\.scenePhase) private var scenePhase
var body: some View {
TestView()
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
print(">> your code is here on scene become active")
case .inactive:
print(">> your code is here on scene become inactive")
case .background:
print(">> your code is here on scene go background")
default:
print(">> do something else in future")
}
}
}
}
I've been testing with Xcode 12 beta 3 and iOS/iPadOS 14 beta 3 and here's what I'm finding. Note that a lot of this involves supporting multiple windows, but the "SwiftUI lifecycle" projects default to turning that on, so I suspect you have it active already. In my original case I was porting an existing SwiftUI app from a SceneDelegate to using the new App struct, so I had multiple window support already active.
Here's the test View I'm using in a new testing app:
struct ContentView: View {
#Environment(\.scenePhase) private var scenePhase
var body: some View {
Text("Hello, world!").padding()
.onChange(of: scenePhase) { phase in
switch phase {
case .background:
print("PHASECHANGE: View entered background")
case .active:
print("PHASECHANGE: View entered active")
case .inactive:
print("PHASECHANGE: View entered inactive")
#unknown default:
print("PHASECHANGE: View entered unknown phase.")
}
}
}
}
(I have identical code in the App & Scene but they never print anything.)
The ScenePhase documentation claims that you can declare onChange inside the App, a Scene or a View. I don't see the App or Scene level versions ever execute, under any circumstance I can engineer, and the View level versions don't seem to execute completely correctly.
On hardware that doesn't support multiple windows (I use a 7th generation iPod touch) the View level closure executes every time. (Full disclosure, this iPod Touch is still running beta 2, but I don't think it's going to matter. Once I update it to b3 I'll mention it here if it matters.) EDIT (It did matter.)
On hardware running beta 2 that doesn't support multiple windows (a 7th generation iPod Touch) I see the app go into the background, back into the foreground, and so forth. On every app launch I'll see "View entered active" print.
On hardware that does support multiple windows (I use an older iPad Pro with the Lightning connector) I don't see the initial scene creation happen. (The first run does not trigger a "View entered active" message.) I do see subsequent background/foreground transitions. If I create a new scene from the iPad multi-tasking UI the second scene will trigger a "View entered active" log. Unfortunately I hadn't run this test on the iPad against beta 2, so I can't say if the behavior changed with b3 or not.
On my iPod Touch running iOS 14 beta 3 I see the same behavior as the iPad: the first launch doesn't print any phase change messages from the view, but does report subsequent background/foreground changes.
On the simulator it always behaves like the iPad hardware, even when I'm simulating an iPod Touch. I suspect this is because the simulator is running under the hood on the Mac and gets multiple window "support" this way. But I do see messages when I put the app in the background while running in the simulator, I'm just missing the initial "View entered active" message that I get from the actual hardware.
One final note: when I return an app from the foreground I first see "View entered inactive" and then I see "View entered active". When I background the app I see "View entered inactive", followed by "View entered background". I think this is expected behavior, but since other parts seem broken I wanted to mention it.
TL;DR:
I think you should be able to see most ScenePhase changes from a View, but you'll miss the initial app launch on iPads or in the simulator. And hopefully they will show up as expected for App and Scene objects in a later beta?
You can use the following extension:
public extension View {
func onScenePhaseChange(phase: ScenePhase, action: #escaping () -> ()) -> some View {
self.modifier(OnScenePhaseChangeModifier(phase: phase, action: action))
}
}
public struct OnScenePhaseChangeModifier: ViewModifier {
#Environment(\.scenePhase) private var scenePhase
public let phase: ScenePhase
public let action: () -> ()
public func body(content: Content) -> some View {
content
.onChange(of: scenePhase) { phase in
if (self.phase == phase) {
action()
}
}
}
}
Final usage:
ContentView()
.onScenePhaseChange(phase: .active) { print("scene activated!") }
.onScenePhaseChange(phase: .background) { print("scene backgrounded!") }
.onScenePhaseChange(phase: .inactive) { print("scene inactive!") }
In my case, I put "#Environment(.scenePhase) private var scenePhase" in ContentView. Then the onChange works in the child views.
Related
I have a SwiftUI app that shows a GKGameCenterViewController. I am hitting a problem which is specific to iPads running iPadOS 16.1 and above in light mode only. Under these circumstances, the child views of GKGameCenterViewController appear as black text on a dark background, which is very difficult to read - see screenshot for the leaderboard "Test" below.
The app runs with versions of iOS/iPadOS 14.0 and above. The problem is very specific to iPads running iPadOS 16.1 and above in light mode. The top-level parent view of GKGameCenterViewController (showing the title "Game Center" and a navigation menu) works fine, only the child views are affected. The problem is not seen on iPhones, nor on iPads running versions of iPadOS 14 or 15. The problem is also not seen on iPads running iPadOS 16.1 and above if dark mode is in operation when GKGameCenterViewController is first shown.
The GKGameCenterViewController is being presented using a UIViewControllerRepresentable as a layer in a ZStack. From the examples I could find, using UIViewControllerRepresentable seems to be the standard way to do it, as also described in How to display Game Center leaderboard with SwiftUI.
It is interesting to note that the problem is not seen when the Game Center dashboard is shown by tapping the Game Center access point. I assume that the access point shows the GKGameCenterViewController in a different way.
The following stripped-down code illustrates how the GKGameCenterViewController is being shown. This is a standalone app that can be used to reproduce the problem. However, for Game Center authentication to work and for the overview of leaderboards to show, the following needs to be done too:
in Xcode, Game Center needs to be added as a capability to the app target
the app needs to be added to App Store Connect
Game Center needs to be enabled for the app version in App Store Connect
a leaderboard needs to be added.
import SwiftUI
import GameKit
#main
struct GameCenterBlackoutApp: App {
var body: some Scene {
WindowGroup {
MyContentView()
}
}
}
struct MyContentView: View {
#State private var showingGameCenter = false
var body: some View {
ZStack {
Button("Show Game Center leaderboards") {
showingGameCenter = true
}
if showingGameCenter {
MyGameCenterView(showingGameCenter: $showingGameCenter)
.ignoresSafeArea()
}
}
.onAppear {
// Authenticate the local player
GKLocalPlayer.local.authenticateHandler = handleAuthenticationOutcome
}
}
private func handleAuthenticationOutcome(vc: UIViewController?, error: Error?) {
if let error {
#if DEBUG
print("Failed to authenticate player: \(error)")
#endif
}
// Prepare and show the GameCenter access point.
// If authentication failed then the access point takes
// the form of a button to sign in
GKAccessPoint.shared.location = .topTrailing
GKAccessPoint.shared.showHighlights = false
GKAccessPoint.shared.isActive = true
}
}
/// A Bridge between the Game Center view controller and its wrapper
final class MyCoordinator : NSObject, GKGameCenterControllerDelegate {
#Binding private var showingGameCenter: Bool
init(showingGameCenter: Binding<Bool>) {
self._showingGameCenter = showingGameCenter
}
func gameCenterViewControllerDidFinish(
_ gameCenterViewController: GKGameCenterViewController
) {
gameCenterViewController.dismiss(animated:false)
showingGameCenter = false
}
}
/// A wrapper for GKGameCenterViewController
struct MyGameCenterView: UIViewControllerRepresentable {
typealias Coordinator = MyCoordinator
/// Binding to the state variable that controls the visibility of the Game Center layer
#Binding private var showingGameCenter: Bool
init(showingGameCenter: Binding<Bool>) {
self._showingGameCenter = showingGameCenter
}
/// Factory function for the Bridge between the GKGameCenterViewController and this wrapper view
func makeCoordinator() -> Coordinator {
MyCoordinator(showingGameCenter: $showingGameCenter)
}
/// Creates the GKGameCenterViewController
func makeUIViewController(
context: UIViewControllerRepresentableContext<MyGameCenterView>
) -> GKGameCenterViewController {
let result = GKGameCenterViewController(state: .leaderboards)
result.gameCenterDelegate = context.coordinator
return result
}
/// Stub implementation of protocol method
func updateUIViewController(
_ gameCenterViewController: GKGameCenterViewController,
context: UIViewControllerRepresentableContext<MyGameCenterView>
) {
// NOP
}
}
I would be really grateful for any workaround that will resolve the black text issue as described and allow the child views of GKGameCenterViewController to be shown in a normal, readable foreground color. I have tried all of the following, none of which made any difference:
setting .dark or .light as .overrideUserInterfaceStyle on the view controller
applying .environment(\.colorScheme, .dark) to the UIViewControllerRepresentable or to a higher-level parent
applying .dark or .white as .preferredColorScheme on the UIViewControllerRepresentable
using modifier .toolbarColorScheme(all combinations of parameters) on the UIViewControllerRepresentable
applying Color.white as .foregroundColor on the UIViewControllerRepresentable
using black or white background behind the UIViewControllerRepresentable
showing the UIViewControllerRepresentable as an overlay instead of as a ZStack layer
not enabling the Game Center access point
using other GKGameCenterViewControllerState values in the init call
using other init functions for GKGameCenterViewController
using deprecated init functions and setters on GKGameCenterViewController.
BTW, I also reported the issue to Apple, but they have not been able to help.
Why is my SwiftUI Swipe Action behaving like this?
I don't now how to add a GIF in stack overflow so here is a imagur link https://imgur.com/a/9MqjIgX.
If you don't want to click on external links here is a image from the GIF:
My View:
struct MyView: View {
#State var shops = [Shop.empty(), Shop.empty(), Shop.empty(), Shop.empty(), Shop.empty()]
var body: some View {
NavigationView {
List($shops) { $shop in
Text(shop.name)
.swipeActions {
Button {
shop.toggleFavourite()
} label: {
Image(systemName: "star")
}
}
}
}
}
}
the shop struct:
struct Shop: Hashable, Identifiable {
var id: UUID
var favourite: Bool
init(id: UUID){
self.id = id
self.favourite = UserDefaults.standard.bool(forKey: id.uuidString)
}
mutating func toggleFavourite() {
favourite.toggle()
UserDefaults.standard.set(favourite, forKey: id.uuidString)
}
static func empty() -> Shop{
Shop(id: UUID())
}
}
But I can't sadly I can't give you a working example, because I tried to run this code in a fresh app and it worked, without the Bug. On the same device. And I don't understand why, because I also put this view in the root of my old project, just for testing, and the bug stayed there.
I was able to figure out, that if I commented out this line:
UserDefaults.standard.set(favourite, forKey: id.uuidString)
my code would work. But unfortunately I can't just leave out this line of code.
I tried several things, including wrapping this line into DispatchQueue.main.async {} and DispatchQueue.main.sync {}, same with the DispatchQueue.global(). I also added delays. Short delays wouldn't work at all (under .5 seconds) and longer delays would just delay the view bug.
Of course I also tried wrapping this line into a separate function, and so on.
There are two mayor points, why I'am so confused:
Why is the line, that sets this to the Userdefaults even influencing the view? I mean I checked with a print statement, that the initializer, which is the only section in my code that checks this Userdefaultvalue, only gets called when the view gets initialized.
Why does the code work in a different project?
I know since I can't provide a working example of my bug it's hard for you to figure out whats wrong. If you have any ideas, I would be very happy!
I'm writing a MacOS document app using the SwiftUI App lifecycle, and all the tricks I see here and elsewhere for sending a menu action to the active window depend on using platform specific implementation, which is (mostly) unavailable in a SwiftUI Lifecycle app. What I'm looking for is something like SideBarCommands(), which adds a menu item that, when selected by mouse or command key, toggles the appearance of the sidebar in the active window. All the Command examples I have seen thus far are trivial, none address a multi-document, multi-window use case.
Given a ContentView declared thusly:
struct ContentView: View {
#Binding var document: TickleDocument
var body: some View {
TextEditor(text: $document.text)
}
public func menuTickle() {
document.text = "Wahoo!"
}
}
and a command, which is added via:
struct TickleApp: App {
public static var target:TickleDocument?
var body: some Scene {
let docGroup = DocumentGroup(newDocument: TickleDocument()) { file in
ContentView(document: file.$document)
}
docGroup
.commands {
CommandMenu("App Tickles") {
Button("Tickle The ContentView") {
// Here's where I need to call menuTickle() on the active ContentView
}.keyboardShortcut("t")
}
}
}
}
}
What do I need to do so the button closure can call menuTickle() on the active ContentView? I know it can be done, because SideBarCommands() does it (unless Apple is using some non-public API to do it...).
For bonus points, tell me how I can detect whether or not I'm the active ContentView while body is being evaluated, and how I can detect when it changes! Tracking the Environment variable scenePhase is worthless - it always reports active, and never changes.
My question is a duplicate of this one.
The answer to that question contains a link to a solution that I have verified works, and can be found here
I'm dealing with what I am nearly certain to be a SwiftUI/Catalyst bug and am looking for a solution to get around it.
In the following code, about 30% of the time (5/15 in my tests), once the controls are revealed, the Toggle elements do not respond to clicks (and thus do not turn on/off).
I'm testing on Xcode 12.3 on Big Sur 11.1, running this code in Catalyst. It does work as expected 100% of the time as far as I can tell on iOS 14.3.
struct ContentView: View {
var body: some View {
ScrollView {
Row()
Row()
}
.padding()
}
}
struct Row : View {
#State private var showControls = false
#State private var toggleOn = false
var body: some View {
VStack {
HStack {
Text("Top section")
Button("\(showControls ? "Hide" : "Show") controls") {
showControls.toggle()
}
}
.frame(height: 100)
if showControls {
HStack {
Toggle("Toggle", isOn: $toggleOn)
Toggle("Toggle", isOn: $toggleOn)
}
}
}
}
}
The problem seems to come from having the Rows embedded in the ScrollView. The problem disappears completely if the controls start in their visible state (ie showControls = true), and only happens when they get revealed (ie showControls.toggle()) after the app starts.
I've also noticed that while Toggle and Slider fail about 30% of the time, a plain Button seems to be responsive 100% of the time.
The view debugger doesn't show anything 'in front' of the views that would be intercepting clicks.
I've tried changing to a List, which solves the problem, but yields other unfortunate side effects in behavior that I'd like to avoid in my real non-trivial app.
Can anyone else think of a reliable solution to avoiding this?
I am creating an App where the login / register part is inside a modal, which is shown if the user is not logged in.
The problem is, that the user can dismiss the modal by swiping it down...
Is it possible to prevent this?
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) { // This needs to be non-dismissible
LoginRegister()
}
}
Second example:
I am using a modal to ask for information. The user should not be able to quit this process except by dismissing the modal with save button. The user has to input information before the button works. Unfortunately the modal can be dismissed by swiping it down.
Is it possible to prevent this?
iOS 15 and later:
Use .interactiveDismissDisabled(true) on the sheet, that's all.
Prev iOS 15:
You can try to do this by using a highPriorityGesture. Of course the blue Rectangle is only for demonstration but you would have to use a view which is covering the whole screen.
struct ModalViewNoClose : View {
#Environment(\.presentationMode) var presentationMode
let gesture = DragGesture()
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 300, height: 600)
.highPriorityGesture(gesture)
.overlay(
VStack{
Button("Close") {
self.presentationMode.value.dismiss()
}.accentColor(.white)
Text("Modal")
.highPriorityGesture(gesture)
TextField("as", text: .constant("sdf"))
.highPriorityGesture(gesture)
} .highPriorityGesture(gesture)
)
.border(Color.green)
}
}
This is a common problem and a "code smell"... well not really code but a "design pattern smell" anyway.
The problem is that you are making your login process part of the rest of the app.
Instead of presenting the LoginRegister over the App you should really be showing either App or LoginRegister.
i.e. you should have some state object like userLoggedIn: Bool or something and depending on that value you should show either App or LoginRegister.
Just don't have both in the view hierarchy at the same time. That way your user won't be able to dismiss the view.
If you dont mind using Introspect:
import Introspect
#available(iOS 13, *)
extension View {
/// A Boolean value indicating whether the view controller enforces a modal behavior.
///
/// The default value of this property is `false`. When you set it to `true`, UIKit ignores events
/// outside the view controller's bounds and prevents the interactive dismissal of the
/// view controller while it is onscreen.
public func isModalInPresentation(_ value: Bool) -> some View {
introspectViewController {
$0.isModalInPresentation = value
}
}
}
Usage:
.sheet {
VStack {
...
}.isModalInPresentation(true)
}
iOS 15+
Starting from iOS 15 you can use interactiveDismissDisabled.
You just need to attach it to the sheet:
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) {
LoginRegister()
.interactiveDismissDisabled(true)
}
}
Regarding your second example, you can pass a variable to control when the sheet is disabled:
.interactiveDismissDisabled(!isAllInformationProvided)
You can find more information in the documentation.
theoretically this may help you (I didn't tryed it)
private var isDisplayedBind: Binding<Bool>{ Binding(get: { true }, set: { _ = $0 }) }
and usage:
content
.sheet(isPresented: isDisplayedBind) { some sheet }