Title and sashColor not appearing on customized long-look notification with WKUserNotificationHostingController - swiftui

I am building custom long-look notifications in an Apple Watch app, but for some reason the title defined in the notification's UNMutableNotificationContent is not showing on the long-look notification, and the custom sashColor I’m defining is not used.
In the “Transition to the Long-Look Interface” section of Presenting Notifications on Apple Watch on Apple’s website, there is a screenshot that shows what I would expect to see: a title for the notification in the sash, and a custom sash color.
I built an example app (code below) to isolate the issue.
Here is a screenshot of the notification in my app:
I expect to see the title (“Take Action!”) where the line is, and the sashColor as the background color for the circled region, based on my code.
The short-look of the notification does show the title briefly before it transitions to the long-look (it was hard to get a good screenshot, but here is one as it was animating into the long-look):
Showing or hiding the notification title is not mentioned anywhere that I can find in the documentation, so I expect that to show up automatically since it’s part of the notification.
For the sashColor override, I referred to Customizing Your Long-Look Interface on Apple’s website.
Is there something else specific I need to do to show the title on my customized long-look notification, and get sashColor to work?
Example App
To recreate the issue, create a watchOS app with companion iOS app in Xcode. I called it CustomWatchNotifications.
I updated the main iOS app file to this, with a simple class to request notification permission and send a test notification, which gets passed into the view:
import SwiftUI
import UserNotifications
#main
struct CustomWatchNotificationsApp: App
{
let notifications = NotificationController()
var body: some Scene
{
WindowGroup
{
ContentView(notifications: notifications)
}
}
}
class NotificationController
{
func requestPermissions()
{
Task {
try await UNUserNotificationCenter.current()
.requestAuthorization(
options: [.alert, .sound])
}
}
func scheduleNotification()
{
let content = UNMutableNotificationContent()
content.title = "Take Action!"
content.categoryIdentifier = "takeActionCategory"
content.sound = .default
// Schedule a new notification 5 seconds from now,
// so there is enough time to lock the phone screen
// to deliver notification to Apple Watch.
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: 5,
repeats: false)
let request = UNNotificationRequest(
identifier: "takeAction",
content: content,
trigger: trigger)
UNUserNotificationCenter.current()
.add(request)
}
}
This is the ContentView for the iOS app, which just includes the two buttons:
import SwiftUI
struct ContentView: View
{
let notifications: NotificationController
var body: some View
{
NavigationView
{
Form
{
// Request notification permissions
Button
{
notifications.requestPermissions()
} label: {
Text("Request Notification Permissions")
}
// Schedule notification
Button
{
notifications.scheduleNotification()
} label: {
Text("Schedule Notification")
}
}
}
}
}
On the watchOS side, I updated the main app file to include a custom View for the notification, inside a WKUserNotificationHostingController for this specific notification category:
import SwiftUI
import UserNotifications
#main
struct CustomWatchNotifications_Watch_AppApp: App
{
var body: some Scene
{
WindowGroup
{
ContentView()
}
WKNotificationScene(
controller: TakeActionNotificationController.self,
category: "takeActionCategory"
)
}
}
struct TakeActionNotificationView: View
{
var body: some View
{
Text("This is a test.")
}
}
class TakeActionNotificationController:
WKUserNotificationHostingController<TakeActionNotificationView>
{
// This does not seem to have an effect on sashColor.
override class var sashColor: Color?
{
return .red
}
override var body: TakeActionNotificationView
{
return TakeActionNotificationView()
}
// This has to be here for custom notification to show up.
override func didReceive(_ notification: UNNotification)
{}
}
When you build and run on real devices, make sure the watchOS app is installed before schedule the test notification. Once you schedule the test notification, lock the iPhone screen immediately so the notification gets delivered to Apple Watch.

Related

GKGameCenterViewController showing as black text on dark background on iPads running iPadOS 16

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.

SwiftUI app - Login persistence code changes - navigation link not working after userDefault code change

In our current swiftUI app, if user closes the app they will be asked to login again - we are trying to change this behaviour by using userDefaults.
Without userDefaults code changes below LoginView directs to the home screen after login/registration using the Navigation link.
Issue:
We made below code changes to the View and LoginViewModel and when login / register button is pressed the LoginView is not taking to the home screen. It just shows the login/signup page with the login successful or registration successful message.
I have debugged in Xcode and loginOrSignUpSuccessful variable value is set to true after login button press.
Why is navigationlink not directing to MainLandingTabView Screen after login or registration?
Code Changes made to the LoginViewModel
Variable & View Code Snippet
#Published var loginOrSignUpSuccessful = UserDefaults.standard.bool(forKey: "loginOrSignUpSuccessful")
{ didSet{ UserDefaults.standard.set(self.loginOrSignUpSuccessful, forKey:"loginOrSignUpSuccessful") } }
self.loginOrSignUpSuccessful = true
UserDefaults.standard.set(true, forKey: "loginOrSignUpSuccessful")
View Code Snippet:
struct SignUpLogin: View {
private var showMainLandingTabViewLink: some View
{
NavigationLink(destination: MainLandingTabView().environmentObject(signupLoginViewModelStateObject).navigationBarBackButtonHidden(true), isActive: $signupLoginViewModelStateObject.loginOrSignUpSuccessful, label: {EmptyView()})
}
var body: some View
{
NavigationView {
ZStack(alignment: .leading) { // all the email & password, button fields.
}
.background(showMainLandingTabViewLink)
}
.environmentObject(signupLoginViewModelStateObject) .navigationBarHidden(true)
// Used to hide the back buttom if coming from NavigationView
}

In a SwiftUI AppLifecycle Document App, how can I get a menu command in active ContentView?

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

Not Receiving scenePhase Changes

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.

Is it possible to make a modal non-dismissible in SwiftUI?

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 }