I have an app where I have several buttons whose actions are shielded by a confirmation dialog. For example:
#State private var confirmDeleteAll: Bool = false
var body: some View {
// ...
Button {
confirmDeleteAll = true
} label: {
Label("Delete all", systemImage: "trash")
}
.confirmationDialog("Delete all data", isPresented: $confirmDeleteAll) {
Button("Delete all", role: .destructive, action: deleteAll)
} message: {
Text("This will wipe all data in the app")
}
// ...
}
These all work fine, and the button in the confirmation dialog shows up in red as expected (seen here on an iPad):
But as I have a load of these, I'd like to refactor the code to make the pattern a little simpler. My principle in the refactor is:
Create a button with the ultimate action to take defined in the button's action argument.
Apply a custom button style which replaces the button's action with displaying a confirmation dialog - inside which is a destructive button that, when pressed, performs the supplied action
That gives me code that looks like
struct ConfirmationButtonStyle: PrimitiveButtonStyle {
var title: LocalizedStringKey
var message: LocalizedStringKey
init(_ title: LocalizedStringKey,
message: LocalizedStringKey = "") {
self.title = title
self.message = message
}
#State private var showConfirmationDialog: Bool = false
func makeBody(configuration: Configuration) -> some View {
Button(role: configuration.role) {
showConfirmationDialog = true
} label: {
configuration.label
}
.confirmationDialog(title,
isPresented: $showConfirmationDialog) {
Button(role: .destructive, action: configuration.trigger) {
configuration.label
}
} message: {
Text(message)
}
}
}
extension PrimitiveButtonStyle where Self == ConfirmationButtonStyle {
static func confirm(
_ title: LocalizedStringKey,
message: LocalizedStringKey
) -> some PrimitiveButtonStyle {
ConfirmationButtonStyle(title, message: message)
}
}
My refactored button then becomes:
Button(action: deleteAll) {
Label("Delete all", systemImage: "trash")
}
.buttonStyle(.confirm(
"Delete all data",
message: "This will wipe all data in the app"
))
From a functional standpoint this works fine. Visually, though, despite the confirmationDialog's button clearly being marked as destructive, its colour reverts to my application's accentColor, which in this case is purple:
From a code point of view I can't see why the destructive role would not be observed. Am I missing something basic here?
Related
I’m trying to show alerts in a sheet in SwiftUI. I have Cancel and Save buttons on the sheet and both of them are dismissed after the action is done.If there is an error on saving, an alert is pop upped. However, the sheet can not be dismissed after the alert is shown. Both save and cancel can not be dismissed after the alert dismiss is tapped. I can not figure out the reason. Any help would be appreciated. Thank you.
Related code
.navigationBarItems(
leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
.foregroundColor(Color("OrangeColor"))
.font(.custom("Montserrat-Medium", size: 18))
},
trailing:
Button(action: {
if selectedBook == nil {
errorInfo = AlertInfo( id: .bookNotSelectedError,
title: "Please choose a book",
message: "")
}
if quote.isEmpty {
errorInfo = AlertInfo( id: .quoteEmptyError,
title: "Please choose a quote",
message: "")
}
if let book = selectedBook {
// Save operations
}
self.presentationMode.wrappedValue.dismiss()
})
{
Text("Save")
.foregroundColor(Color("OrangeColor"))
.font(.custom("Montserrat-Medium", size: 18))
}
.alert(item: $errorInfo, content: { info in
Alert(title: Text(info.title),
message: Text(info.message))
})
)
Alert Info Struct
struct AlertInfo: Identifiable {
enum AlertType {
case saveError
case bookNotSelectedError
case quoteEmptyError
case totalPageError
case currentPageError
}
let id: AlertType
let title: String
let message: String
}
Your SAVE Button checks for errors but then always calls dismiss() – so the Alert shows up, but vanishes immediately.
Also you have to check through the errors using ..else if...
This is how it should work:
Button(action: {
if selectedBook == nil {
errorInfo = AlertInfo( id: .bookNotSelectedError,
title: "Please choose a book",
message: "")
}
else if quote.isEmpty {
errorInfo = AlertInfo( id: .quoteEmptyError,
title: "Please choose a quote",
message: "")
}
else if let book = selectedBook {
// only call dismiss() after save was successful
presentationMode.wrappedValue.dismiss()
}
// NO dismiss here!
})
{
Text("Save")
}
Please also note that Alert and alert(item:content:) are deprecated.
I don't understand why SwiftUI NavigationLink's isActive behaves as if it has it's own state. Even though I pass a constant to it, the back button overrides the value of the binding once pressed.
Code:
import Foundation
import SwiftUI
struct NavigationLinkPlayground: View {
#State
var active = true
var body: some View {
NavigationView {
VStack {
Text("Navigation Link playground")
Button(action: { active.toggle() }) {
Text("Toggle")
}
Spacer()
.frame(height: 40)
FixedNavigator(active: active)
}
}
}
}
fileprivate struct FixedNavigator: View {
var active: Bool = true
var body: some View {
return VStack {
Text("Fixed navigator is active: \(active)" as String)
NavigationLink(
destination: SecondScreen(),
// this is technically a constant!
isActive: Binding(
get: { active },
set: { newActive in print("User is setting to \(newActive), but we don't let them!") }
),
label: { Text("Go to second screen") }
)
}
}
}
fileprivate struct SecondScreen: View {
var body: some View {
Text("Nothing to see here")
}
}
This is a minimum reproducible example, my actual intention is to handle the back button press manually. So when the set inside the Binding is called, I want to be able to decide when to actually proceed. (So like based on some validation or something.)
And I don't understand what is going in and why the back button is able to override a constant binding.
Your use of isActive is wrong. isActive takes a binding boolean and whenever you set that binding boolean to true, the navigation link gets activated and you are navigated to the destination.
isActive does not control whether the navigation link is clickable/disbaled or not.
Here's an example of correct use of isActive. You can manually trigger the navigation to your second view by setting activateNavigationLink to true.
EDIT 1:
In this new sample code, you can disable and enable the back button at will as well:
struct ContentView: View {
#State var activateNavigationLink = false
var body: some View {
NavigationView {
VStack {
// This isn't visible and should take 0 space from the screen!
// Because its `label` is an `EmptyView`
// It'll get programmatically triggered when you set `activateNavigationLink` to `true`.
NavigationLink(
destination: SecondScreen(),
isActive: $activateNavigationLink,
label: EmptyView.init
)
Text("Fixed navigator is active: \(activateNavigationLink)" as String)
Button("Go to second screen") {
activateNavigationLink = true
}
}
}
}
}
fileprivate struct SecondScreen: View {
#State var backButtonActivated = false
var body: some View {
VStack {
Text("Nothing to see here")
Button("Back button is visible: \(backButtonActivated)" as String) {
backButtonActivated.toggle()
}
}
.navigationBarBackButtonHidden(!backButtonActivated)
}
}
Can anyone think how to call an action when double clicking a NavigationLink in a List in MacOS? I've tried adding onTapGesture(count:2) but it does not have the desired effect and overrides the ability of the link to be selected reliably.
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item)) {
ItemRow(itemRow: item) //<-my row view
}.buttonStyle(PlainButtonStyle())
.simultaneousGesture(TapGesture(count:2)
.onEnded {
print("double tap")
})
}
}
}
}
EDIT:
I've set up a tag/selection in the NavigationLink and can now double or single click the content of the row. The only trouble is, although the itemDetail view is shown, the "active" state with the accent does not appear on the link. Is there a way to either set the active state (highlighted state) or extend the NavigationLink functionality to accept double tap as well as a single?
#State var selection:String?
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item), tag: item.id, selection: self.$selection) {
ItemRow(itemRow: item) //<-my row view
}.onTapGesture(count:2) { //<- Needed to be first!
print("doubletap")
}.onTapGesture(count:1) {
self.selection = item.id
}
}
}
}
}
Here's another solution that seems to work the best for me. It's a modifier that adds an NSView which does the actual handling. Works in List even with selection:
extension View {
/// Adds a double click handler this view (macOS only)
///
/// Example
/// ```
/// Text("Hello")
/// .onDoubleClick { print("Double click detected") }
/// ```
/// - Parameters:
/// - handler: Block invoked when a double click is detected
func onDoubleClick(handler: #escaping () -> Void) -> some View {
modifier(DoubleClickHandler(handler: handler))
}
}
struct DoubleClickHandler: ViewModifier {
let handler: () -> Void
func body(content: Content) -> some View {
content.background {
DoubleClickListeningViewRepresentable(handler: handler)
}
}
}
struct DoubleClickListeningViewRepresentable: NSViewRepresentable {
let handler: () -> Void
func makeNSView(context: Context) -> DoubleClickListeningView {
DoubleClickListeningView(handler: handler)
}
func updateNSView(_ nsView: DoubleClickListeningView, context: Context) {}
}
class DoubleClickListeningView: NSView {
let handler: () -> Void
init(handler: #escaping () -> Void) {
self.handler = handler
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
if event.clickCount == 2 {
handler()
}
}
}
https://gist.github.com/joelekstrom/91dad79ebdba409556dce663d28e8297
I've tried all these solutions but the main issue is using gesture or simultaneousGesture overrides the default single tap gesture on the List view which selects the item in the list. As such, here's a simple method I thought of to retain the default single tap gesture (select row) and handle a double tap separately.
struct ContentView: View {
#State private var monitor: Any? = nil
#State private var hovering = false
#State private var selection = Set<String>()
let fruits = ["apple", "banana", "plum", "grape"]
var body: some View {
List(fruits, id: \.self, selection: $selection) { fruit in
VStack {
Text(fruit)
.frame(maxWidth: .infinity, alignment: .leading)
.clipShape(Rectangle()) // Allows the hitbox to be the entire word not the if you perfectly press the text
}
.onHover {
hovering = $0
}
}
.onAppear {
monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) {
if $0.clickCount == 2 && hovering { // Checks if mouse is actually hovering over the button or element
print("Double Tap!") // Run action
}
return $0
}
}
.onDisappear {
if let monitor = monitor {
NSEvent.removeMonitor(monitor)
}
}
}
}
This works if you just need to single tap to select and item, but only do something if the user double taps. If you want to handle a single tap and a double tap, there still remains the problem of single tap running when its a double tap. A potential work around would be to capture and delay the single tap action by a few hundred ms and cancel it if it was a double tap action
Use simultaneous gesture, like below (tested with Xcode 11.4 / macOS 10.15.5)
NavigationLink(destination: Text("View One")) {
Text("ONE")
}
.buttonStyle(PlainButtonStyle()) // << required !!
.simultaneousGesture(TapGesture(count: 2)
.onEnded { print(">> double tap")})
or .highPriorityGesture(... if you need double-tap has higher priority
Looking for a similar solution I tried #asperi answer, but had the same issue with tappable areas as the original poster. After trying many variations the following is working for me:
#State var selection: String?
...
NavigationLink(destination: HistoryListView(branch: string), tag: string, selection: self.$selection) {
Text(string)
.gesture(
TapGesture(count:1)
.onEnded({
print("Tap Single")
selection = string
})
)
.highPriorityGesture(
TapGesture(count:2)
.onEnded({
print("Tap Double")
})
)
}
I have two alert which is called if the boolean is true.
Alert - 1 - It is called if there is any issues with the bluetooth state other than powered on.This is called directly from a swift package named BLE. The code snippet is below.
Alert - 2 - It is called when you want to unpair the peripheral giving the user two options.Unpair or remain on the same page.
Issue :
Both the alert seems to be working fine but if they are not placed in the same view. When I place the alert in the same view the last displayed alert is called from the sequence top to bottom.
The OS reads the first alert but only activates the second alert if it's called.
Is there a way to make both alert functional if they are called.
I referred to below solution but i was getting the same results.
Solution 1 and Solution 2
There are 2 Code snippets
1. Main Application
import SwiftUI
import BLE
struct Dashboard: View {
#EnvironmentObject var BLE: BLE
#State private var showUnpairAlert: Bool = false
private var topLayer: HeatPeripheral {
self.BLE.peripherals.baseLayer.top
}
var body: some View {
VStack(alignment: .center, spacing: 0) {
// MARK: - Menu Bar
VStack(alignment: .center, spacing: 4) {
Button(action: {
print("Unpair tapped!")
self.showUnpairAlert = true
}) {
HStack {
Text("Unpair")
.fontWeight(.bold)
.font(.body)
}
.frame(minWidth: 85, minHeight: 35)
.cornerRadius(30)
}
}
}
.onAppear(perform: {
self.BLE.update()
})
// Alert 1 - It is called if it meets one of the cases and returns the alert
// It is presented in the function centralManagerDidUpdateState
.alert(isPresented: $BLE.showStateAlert, content: { () -> Alert in
let state = self.BLE.centralManager!.state
var message = ""
switch state {
case .unknown:
message = "Bluetooth state is unknown"
case .resetting:
message = "Bluetooth is resetting..."
case .unsupported:
message = "This device doesn't have a bluetooth radio."
case .unauthorized:
message = "Turn On Bluetooth In The Settings App to Allow Battery to Connect to App."
case .poweredOff:
message = "Turn On Bluetooth to Allow Battery to Connect to App."
break
#unknown default:
break
}
return Alert(title: Text("Bluetooth is \(self.BLE.getStateString())"), message: Text(message), dismissButton: .default(Text("OK")))
})
// Alert 2 - It is called when you tap the unpair button
.alert(isPresented: $showUnpairAlert) {
Alert(title: Text("Unpair from \(checkForDeviceInformation())"), message: Text("*Your peripheral command will stay on."), primaryButton: .destructive(Text("Unpair")) {
self.unpairAndSetDefaultDeviceInformation()
}, secondaryButton: .cancel())
}
}
func unpairAndSetDefaultDeviceInformation() {
defaults.set(defaultDeviceinformation, forKey: Keys.deviceInformation)
disconnectPeripheral()
print("Pod unpaired and view changed to Onboarding")
self.presentationMode.wrappedValue.dismiss()
DispatchQueue.main.async {
self.activateLink = true
}
}
func disconnectPeripheral(){
if skiinBLE.peripherals.baseLayer.top.cbPeripheral != nil {
self.skiinBLE.disconnectPeripheral()
}
}
}
2. BLE Package
import SwiftUI
import Combine
import CoreBluetooth
public class BLE: NSObject, ObservableObject {
public var centralManager: CBCentralManager? = nil
public let baseLayerServices = "XXXXXXXXXXXXXXX"
let defaults = UserDefaults.standard
#Published public var showStateAlert: Bool = false
public func start() {
self.centralManager = CBCentralManager(delegate: self, queue: nil, options: nil)
self.centralManager?.delegate = self
}
public func getStateString() -> String {
guard let state = self.centralManager?.state else { return String() }
switch state {
case .unknown:
return "Unknown"
case .resetting:
return "Resetting"
case .unsupported:
return "Unsupported"
case .unauthorized:
return "Unauthorized"
case .poweredOff:
return "Powered Off"
case .poweredOn:
return "Powered On"
#unknown default:
return String()
}
}
}
extension BLE: CBCentralManagerDelegate {
public func centralManagerDidUpdateState(_ central: CBCentralManager) {
print("state: \(self.getStateString())")
if central.state == .poweredOn {
self.showStateAlert = false
if let connectedPeripherals = self.centralManager?.retrieveConnectedPeripherals(withServices: self.baseLayerServices), connectedPeripherals.count > 0 {
print("Already connected: \(connectedPeripherals.map{$0.name}), self.peripherals: \(self.peripherals)")
self.centralManager?.stopScan()
}
else {
print("scanForPeripherals")
self.centralManager?.scanForPeripherals(withServices: self.baseLayerServices, options: nil)
}
}
else {
self.showStateAlert = true // Alert is called if there is any issue with the state.
}
}
}
Thank You !!!
The thing to remember is that view modifiers don't really just modify a view, they return a whole new view. So the first alert modifier returns a new view that handles alerts in the first way. The second alert modifier returns a new view that modifies alerts the second way (overwriting the first method) and that's the only one that ultimately is in effect. The outermost modifier is what matters.
There are couple things you can try, first try attaching the different alert modifiers to two different view, not the same one.
Second you can try the alternate form of alert that takes a Binding of an optional Identifiable and passes that on to the closure. When value is nil, nothing happens. When the state of changes to something other than nil, the alert should appear.
Here's an example using the alert(item:) form as opposed to the Bool based alert(isPresented:).
enum Selection: Int, Identifiable {
case a, b, c
var id: Int { rawValue }
}
struct MultiAlertView: View {
#State private var selection: Selection? = nil
var body: some View {
HStack {
Button(action: {
self.selection = .a
}) { Text("a") }
Button(action: {
self.selection = .b
}) { Text("b") }
}.alert(item: $selection) { (s: Selection) -> Alert in
Alert(title: Text("selection: \(s.rawValue)"))
}
}
}
I am now learning to create a sample code for SwiftUI using the official version of Xcode11.
I wrote a simple code to show and hide modal.
This code adds a button to the list and displays a modal.
Strangely, however, the modal no longer appears when the button is tapped again after closing.
Is there a reason for this or any solution?
Occurs when there is a button in the list, but if you delete only the list from the code, the modal can be displayed as many times as you like.
This is the code that causes the bug.
struct ContentView: View {
#State var show_modal = false
var body: some View {
List {
Button(action: {
print("Button Pushed")
self.show_modal = true
}) {
Text("Show Modal")
}.sheet(isPresented: self.$show_modal, onDismiss: {
print("dismiss")
}) {
ModalView()
}
}
}
}
This is a code that does not cause a bug.
struct ContentView: View {
#State var show_modal = false
var body: some View {
Button(action: {
print("Button Pushed")
self.show_modal = true
}) {
Text("Show Modal")
}.sheet(isPresented: self.$show_modal, onDismiss: {
print("dismiss")
}) {
ModalView()
}
}
}
The only difference is whether or not there is a List.
The ModalView code is below.
struct ModalView: View {
// 1. Add the environment variable
#Environment(\.presentationMode) var presentationMode
var body: some View {
// 2. Embed Text in a VStack
VStack {
// 3. Add a button with the following action
Button(action: {
print("dismisses form")
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Dismiss")
}.padding(.bottom, 50)
Text("This is a modal")
}
}
}
When breakpoint is set, print ("Button Pushed") is called every time, but ModalView of .sheet is not called, and naturally the body of ModalView class is not called.
I think the issue is that your .sheet is not on the List itself but on the Button in your code that causes the bug.
Try this instead:
struct ContentView: View {
#State var show_modal = false
var body: some View {
List {
Button(action: {
print("Button Pushed")
self.show_modal = true
}) {
Text("Show Modal")
}
}.sheet(isPresented: self.$show_modal, onDismiss: {
print("dismiss")
}) {
ModalView()
}
}
}