Semi related question: SwiftUI ActionSheet does not dismiss when timer is running
I am currently experiencing an issue with alerts in a project that I am working on. Presented alerts will not dismiss when there is a timer running in the background. Most of the time it requires several clicks of the dismissal button to disappear. I have recreated this issue with as little overhead as possible in a sample project.
My primary project has this issue when trying to display an alert on a different view but I could not reproduce that issue in the sample project. The issue can be reliably replicated by toggling the alert on the same view that the timer is running. I have also tested by removing the binding from the text field to stop the text field view from updating. The alert still fails to dismiss on the first click. I am unsure if there is a way to work around this and am looking for any advice possible.
Xcode 13.0/iOS 15.0 and occurs in iOS 14.0 also
Timerview.swift
struct TimerView: View {
#ObservedObject var stopwatch = Stopwatch()
#State var isAlertPresented:Bool = false
var body: some View {
VStack{
Text(String(format: "%.1f", stopwatch.secondsElapsed))
.font(.system(size: 70.0))
.minimumScaleFactor(0.1)
.lineLimit(1)
Button(action:{
stopwatch.actionStartStop()
}){
Text("Toggle Timer")
}
Button(action:{
isAlertPresented.toggle()
}){
Text("Toggle Alert")
}
}
.alert(isPresented: $isAlertPresented){
Alert(title:Text("Error"),message:Text("I am presented"))
}
}
}
Stopwatch.swift
class Stopwatch: ObservableObject{
#Published var secondsElapsed: TimeInterval = 0.0
#Published var mode: stopWatchMode = .stopped
func actionStartStop(){
if mode == .stopped{
start()
}else{
stop()
}
}
var timer = Timer()
func start() {
secondsElapsed = 0.0
mode = .running
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
self.secondsElapsed += 0.1
}
}
func stop() {
timer.invalidate()
mode = .stopped
}
enum stopWatchMode {
case running
case stopped
}
}
Edit:
Moving the button to a custom view solves the initial problem but is there a solution for when the button needs to interact with the Observable object?
Button(action:{
do{
try stopwatch.actionDoThis()
}catch{
isAlertPresented = true
}
}){
Text("Toggle Alert")
}.alert(isPresented: $isAlertPresented){
Alert(title:Text("Error"),message:Text("I am presented"))
Every time timer runs UI will recreate, since "secondsElapsed" is an observable object. SwiftUI will automatically monitor for changes in "secondsElapsed", and re-invoke the body property of your view.
In order to avoid this we need to separate the button and Alert to another view like below.
struct TimerView: View {
#ObservedObject var stopwatch = Stopwatch()
#State var isAlertPresented:Bool = false
var body: some View {
VStack{
Text(String(format: "%.1f", stopwatch.secondsElapsed))
.font(.system(size: 70.0))
.minimumScaleFactor(0.1)
.lineLimit(1)
Button(action:{
stopwatch.actionStartStop()
}){
Text("Toggle Timer")
}
CustomAlertView(isAlertPresented: $isAlertPresented)
}
}
}
struct CustomAlertView: View {
#Binding var isAlertPresented: Bool
var body: some View {
Button(action:{
isAlertPresented.toggle()
}){
Text("Toggle Alert")
}.alert(isPresented: $isAlertPresented){
Alert(title:Text("Error"),message:Text("I am presented"))
}
}
}
If you really need the ObservedObject or any attribute of it in order to perform any action in case of "OK" action of the alert, you can do a workaround.
struct TimerView: View {
#ObservedObject var stopwatch = Stopwatch()
#State var isResetAccepted: Bool = false
var body: some View {
VStack{
Text(String(format: "%.1f", stopwatch.secondsElapsed))
.font(.system(size: 70.0))
.minimumScaleFactor(0.1)
.lineLimit(1)
Button(action:{
stopwatch.actionStartStop()
}){
Text("Toggle Timer")
}
CustomAlertView(isResetAccepted: $isResetAccepted)
.onChange(of: isResetAccepted) { newValue in
if newValue {
isResetAccepetd = false
stopwatch.reset()
}
}
}
}
}
struct CustomAlertView: View {
#Binding var isResetAccepted: Bool
#State var isAlertPresented: Bool = false
var body: some View {
Button(action:{
isAlertPresented.toggle()
}){
Text("Toggle Alert")
}.alert(isPresented: $isAlertPresented){
Alert(title:Text("Error"),
message:Text("I am presented"),
primaryButton: .destructive(Text("Cancel"), action: {
self.isResetAccepted = false
self.isAlertPresented = false
}),
secondaryButton: .default(Text("OK"), action: {
self.isResetAccepted = true
self.isAlertPresented = false
}))
}
}
}
Related
I have a Voip calling app using CallKit and when call received, it will open a view call IncomingView in a sheet in my SwiftUI app. So far so good. But i want to minimize the sheet and can navigate to other pages and preferably shows a green bar at the navigation (similar to WhatApp) that indicates the call is going on and when i tap there, it should bring back my "IncomingView".
here is my code:
struct MainView: View {
let acceptPublishser = NotificationCenter.default
.publisher(for: Notification.Name.DidCallAccepted)
let endPublisher = NotificationCenter.default
.publisher(for: Notification.Name.DidCallEnd)
var body: some View {
NavigationView {
TabView {
TabListView()
.tabItem {
Label("Home", systemImage: "house.fill")
}
ContactListView()
.tabItem {
Label("Contacts", systemImage: "person.crop.circle")
}
ProfileView()
.tabItem {
Label("Profile", systemImage: "person.crop.circle")
}
}
.padding(0)
.onAppear(){
self.showModal = MyCallDelegate.shared.isIncomingCall
}
.sheet(isPresented: $showModal){
IncomingCallView() // -> Present IncomingCallview() as sheet
}
.accentColor(Color(.green))
}.onReceive(self.acceptPublishser, perform: { output in
showModal = true
})
.onReceive(self.endPublisher, perform: { output in
showModal = false
})
}
}
struct IncomingCallView: View {
var body: some View {
VStack{
Spacer()
Text("callerId").foregroundColor(.white)
Text("Timer").foregroundColor(.white)
}
}
I have an app architecture similar to the below (simplified) code. I use a WorkoutManager StateObject which I initialize in the set up view, then pass down to its children via EnvironmentObject. The problem is that upon dismissing the .sheet there isn't any life cycle event which initializes a new WorkoutManager, which I need in order to be able to start new workouts consecutively. How in this example below can I give WorkoutView the ability to reinitialize WorkoutManager so that it is a clean object?
import SwiftUI
import HealthKit
class WorkoutManager: ObservableObject {
var workout: HKWorkout?
}
struct ContentView: View {
#StateObject var workoutManager = WorkoutManager()
#State var showingWorkoutView = false
var body: some View {
Button {
showingWorkoutView.toggle()
} label: {
Text("Start Workout")
}
.sheet(isPresented: $showingWorkoutView) {
WorkoutView(showingWorkoutView: $showingWorkoutView)
}
}
}
struct WorkoutView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#Binding var showingWorkoutView: Bool
var body: some View {
Text("Workout Started")
.padding()
Button {
showingWorkoutView.toggle()
//Here I want to initialize a new WorkoutManager to clear out the previous workout's state, how?
} label: {
Text("End Workout")
}
}
}
As mentioned in the comments already, the route you probably want to take is reseting the state within the same WorkoutManager. You wouldn't be able to assign a new object to a #StateObject anyway -- you'll end up with compiler errors because of the View's immutable self.
Secondly, I'd suggest that you probably don't want to rely on the Button in your WorkoutView to do this. For example, if the user dismissed the sheet by swiping, that wouldn't get called. Instead, you could listen for the sheet's state in onChange (another method would be using the onDismiss parameter of sheet):
class WorkoutManager: ObservableObject {
var workout: HKWorkout?
func resetState() {
//do whatever you need to do to reset the state
print("Reset state")
}
}
struct ContentView: View {
#StateObject var workoutManager = WorkoutManager()
#State var showingWorkoutView = false
var body: some View {
Button {
showingWorkoutView.toggle()
} label: {
Text("Start Workout")
}
.sheet(isPresented: $showingWorkoutView) {
WorkoutView(showingWorkoutView: $showingWorkoutView)
}
.onChange(of: showingWorkoutView) { newValue in
if !newValue {
workoutManager.resetState()
}
}
}
}
struct WorkoutView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#Binding var showingWorkoutView: Bool
var body: some View {
Text("Workout Started")
.padding()
Button {
showingWorkoutView.toggle()
} label: {
Text("End Workout")
}
}
}
I am excited to see the TextField enhancement: focused(...): https://developer.apple.com/documentation/swiftui/view/focused(_:)
I want to use it to show a very simple SwitfUI view that contains only one TextField that has the focus with keyboard open immediately. Not able to get it work:
struct EditTextView: View {
#FocusState private var isFocused: Bool
#State private var name = "test"
// ...
var body: some View {
NavigationView {
VStack {
HStack {
TextField("Enter your name", text: $name).focused($isFocused)
.onAppear {
isFocused = true
}
// ...
Anything wrong? I have trouble to give it default value.
I was also not able to get this work on Xcode 13, beta 5. To fix, I delayed the call to isFocused = true. That worked!
The theory I have behind the bug is that at the time of onAppear the TextField is not ready to become first responder, so isFocused = true and iOS calls becomeFirstResponder behind the scenes, but it fails (ex. the view hierarchy is not yet done setting up).
struct MyView: View {
#State var text: String
#FocusState private var isFocused: Bool
var body: some View {
Form {
TextEditor(text: $text)
.focused($isFocused)
.onChange(of: isFocused) { isFocused in
// this will get called after the delay
}
.onAppear {
// key part: delay setting isFocused until after some-internal-iOS setup
DispatchQueue.main.asyncAfter(deadline: .now()+0.5) {
isFocused = true
}
}
}
}
}
I was also not able to get this work on Xcode 13, beta 5. To fix, I delayed the call to isFocused = true. That worked!
It also works without delay.
DispatchQueue.main.async {
isFocused = true
}
//This work in iOS 15.You can try it.
struct ContentView: View {
#FocusState private var isFocused: Bool
#State private var username = "Test"
var body: some View {
VStack {
TextField("Enter your username", text: $username)
.focused($isFocused).onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isFocused = true
}
}
}
}
}
I've had success adding the onAppear to the outermost view (in your case NavigationView):
struct EditTextView: View {
#FocusState private var isFocused: Bool
#State private var name = "test"
// ...
var body: some View {
NavigationView {
VStack {
HStack {
TextField("Enter your name", text: $name).focused($isFocused)
}
}
}
.onAppear {
isFocused = true
}
}
// ...
I’m not certain but perhaps your onAppear attached to the TextField isn’t running. I would suggest adding a print inside of the onAppear to confirm the code is executing.
I faced the same problem and had the idea to solve it by embedding a UIViewController so could use viewDidAppear. Here is a working example:
import SwiftUI
import UIKit
struct FocusTestView : View {
#State var presented = false
var body: some View {
Button("Click Me") {
presented = true
}
.sheet(isPresented: $presented) {
LoginForm()
}
}
}
struct LoginForm : View {
enum Field: Hashable {
case usernameField
case passwordField
}
#State private var username = ""
#State private var password = ""
#FocusState private var focusedField: Field?
var body: some View {
Form {
TextField("Username", text: $username)
.focused($focusedField, equals: .usernameField)
SecureField("Password", text: $password)
.focused($focusedField, equals: .passwordField)
Button("Sign In") {
if username.isEmpty {
focusedField = .usernameField
} else if password.isEmpty {
focusedField = .passwordField
} else {
// handleLogin(username, password)
}
}
}
.uiKitOnAppear {
focusedField = .usernameField
// If your form appears multiple times you might want to check other values before setting the focus.
}
}
}
struct UIKitAppear: UIViewControllerRepresentable {
let action: () -> Void
func makeUIViewController(context: Context) -> UIAppearViewController {
let vc = UIAppearViewController()
vc.action = action
return vc
}
func updateUIViewController(_ controller: UIAppearViewController, context: Context) {
}
}
class UIAppearViewController: UIViewController {
var action: () -> Void = {}
override func viewDidLoad() {
view.addSubview(UILabel())
}
override func viewDidAppear(_ animated: Bool) {
// had to delay the action to make it work.
DispatchQueue.main.asyncAfter(deadline:.now()) { [weak self] in
self?.action()
}
}
}
public extension View {
func uiKitOnAppear(_ perform: #escaping () -> Void) -> some View {
self.background(UIKitAppear(action: perform))
}
}
UIKitAppear was taken from this dev forum post, modified with dispatch async to call the action. LoginForm is from the docs on FocusState with the uiKitOnAppear modifier added to set the initial focus state.
It could perhaps be improved by using a first responder method of the VC rather than the didAppear, then perhaps the dispatch async could be avoided.
I have condition to show alert in a view which can able to show from anywhere in the app. Like I want to present it from root view so it can possibly display in all view. Currently what happens when I present from very first view it will display that alert until i flow the same Navigation View. Once any sheets open alert is not displayed on it. Have any solutions in SwiftUI to show alert from one place to entire app.
Here is my current Implementation of code.
This is my contentView where the sheet is presented and also alert added in it.
struct ContentView: View {
#State var showAlert: Bool = false
#State var showSheet: Bool = false
var body: some View {
NavigationView {
Button(action: {
showSheet = true
}, label: {
Text("Show Sheet")
}).padding()
.sheet(isPresented: $showSheet, content: {
SheetView(showAlert: $showAlert)
})
}
.alert(isPresented: $showAlert, content: {
Alert(title: Text("Alert"))
})
}
}
Here from sheet I am toggle the alert and the alert is not displayed.
struct SheetView: View {
#Binding var showAlert: Bool
var body: some View {
Button(action: {
showAlert = true
}, label: {
Text("Show Alert")
})
}
}
here is the error in debug when we toggle button
AlertDemo[14187:3947182] [Presentation] Attempt to present <SwiftUI.PlatformAlertController: 0x109009c00> on <_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__: 0x103908b50> (from <_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__: 0x103908b50>) which is already presenting <_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_: 0x103d05f50>.
Any solution for that in SwiftUI? Thanks in Advance.
I was able to achieve this with this simplified version of what #workingdog suggested in their answer. It works as follows:
create the Alerter class that notifies the top-level and asks to display an alert
class Alerter: ObservableObject {
#Published var alert: Alert? {
didSet { isShowingAlert = alert != nil }
}
#Published var isShowingAlert = false
}
render the alert at the top-most level, for example in your #main struct or the ContentView
#main
struct MyApp: App {
#StateObject var alerter: Alerter = Alerter()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(alerter)
.alert(isPresented: $alerter.isShowingAlert) {
alerter.alert ?? Alert(title: Text(""))
}
}
}
}
set the alert that should be displayed from inside a child view
struct SomeChildView: View {
#EnvironmentObject var alerter: Alerter
var body: some View {
Button("show alert") {
alerter.alert = Alert(title: Text("Hello from SomeChildView!"))
}
}
}
Note on sheets
If you present views as sheets, each sheet needs to implement its own alert, just like MyApp does above.
If you have a NavigationView inside your sheet and present other views within this navigation view in the same sheet, the subsequent sheets can use the first sheet's alert, just like SomeChildView does in my example above.
Here is a possible example solution to show an Alert anywhere in the App.
It uses "Environment" and "ObservableObject".
import SwiftUI
#main
struct TestApp: App {
#StateObject var alerter = Alerter()
var body: some Scene {
WindowGroup {
ContentView().environment(\.alerterKey, alerter)
.alert(isPresented: $alerter.showAlert) {
Alert(title: Text("This is the global alert"),
message: Text("... alert alert alert ..."),
dismissButton: .default(Text("OK")))
}
}
}
}
struct AlerterKey: EnvironmentKey {
static let defaultValue = Alerter()
}
extension EnvironmentValues {
var alerterKey: Alerter {
get { return self[AlerterKey] }
set { self[AlerterKey] = newValue }
}
}
class Alerter: ObservableObject {
#Published var showAlert = false
}
struct ContentView: View {
#Environment(\.alerterKey) var theAlerter
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: SecondView()) {
Text("Click for second view")
}.padding(20)
Button(action: { theAlerter.showAlert.toggle()}) {
Text("Show alert here")
}
}
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct SecondView: View {
#Environment(\.alerterKey) var theAlerter
var body: some View {
VStack {
Button(action: { theAlerter.showAlert.toggle()}) {
Text("Show alert in second view")
}
}
}
}
What should I observe to receive notifications in a View of focus changes on an app, or scene, displayed in an iPaOS Split View?
I'm trying to update some data, for the View, as described here, when the user gives focus back to the app.
Thanks.
Here is a solution that updates pasteDisabled whenever a UIPasteboard.changedNotification is received or a scenePhase is changed:
struct ContentView: View {
#Environment(\.scenePhase) private var scenePhase
#State private var pasteDisabled = false
var body: some View {
Text("Some Text")
.contextMenu {
Button(action: {}) {
Text("Paste")
Image(systemName: "doc.on.clipboard")
}
.disabled(pasteDisabled)
}
.onReceive(NotificationCenter.default.publisher(for: UIPasteboard.changedNotification)) { _ in
updatePasteDisabled()
}
.onChange(of: scenePhase) { _ in
updatePasteDisabled()
}
}
func updatePasteDisabled() {
pasteDisabled = !UIPasteboard.general.contains(pasteboardTypes: [aPAsteBoardType])
}
}