Skip to content

Commit

Permalink
Added custom SettingsLink pre/post action init (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
orchetect committed Oct 2, 2023
1 parent 40e124a commit dbd2726
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 35 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1420"
LastUpgradeVersion = "1500"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
4 changes: 4 additions & 0 deletions Demo/Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
Expand Down Expand Up @@ -239,6 +240,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
Expand Down Expand Up @@ -269,6 +271,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "SettingsAccess Demo";
Expand All @@ -295,6 +298,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "SettingsAccess Demo";
Expand Down
31 changes: 30 additions & 1 deletion Demo/Demo/DemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,46 @@ struct DemoApp: App {
.openSettingsAccess()
}

if #available(macOS 13.0, *) { // MenuBarExtra only available on macOS 13+
MenuBarExtra {
MenuBarExtraMenuView()
// Do not add .openSettingsAccess() here when using a menu-based MenuBarExtra.
// Instead, use SettingsLink(label:preAction:postAction:) in the menu.
} label: {
Text("Demo")
}
}

Settings {
SettingsView()
}
}
}

struct MenuBarExtraMenuView: View {
var body: some View {
SettingsLink {
Text("Settings...")
} preAction: {
// code to run before Settings opens
NSApp.activate(ignoringOtherApps: true) // this does nothing if running from Xcode, but running as a standalone app it will work
} postAction: {
// code to run after Settings opens
}

Divider()

Button("Quit") {
NSApp.terminate(nil)
}
}
}

struct SettingsView: View {
var body: some View {
VStack {
Text("Settings window.")
.frame(idealWidth: 400, maxWidth: .infinity,
.frame(idealWidth: 400, maxWidth: .infinity,
idealHeight: 200, maxHeight: .infinity)
}
.padding()
Expand Down
34 changes: 27 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ As of macOS 14 Sonoma:
1. There is no way to detect when the user has clicked this button if additional code is desired to run before or after the opening of the `Settings` scene.
2. There is **no** way to programmatically open the `Settings` scene.

- These restrictons become problematic in many scenarios. Some examples that are currently impossible without **SettingsAccess**:
- These restrictions become problematic in many scenarios. Some examples that are currently impossible without **SettingsAccess**:
- You are building a window-based `MenuBarExtra` and want to have a button that opens `Settings` and also dismisses the window.
- You want to open the `Settings` scene in response to a user action in your application that requires the user manipulate a setting that may be invalid.

## Solution

- **SettingsAccess** provides an environment method called `openSettings()` that can be called anywhere in the view hierarchy to programmatically open the Settings scene. (See [Getting Started](#Getting-Started) below for an example of its usage.)

- Due to SwiftUI limitations, this method is not usable inside of menus (including menu-based MenuBarExtra). In that scenario, a `SettingsLink(label:preAction:postAction:)` initializer is provided to allow custom code to run before and/or after the `Settings` scene is opened.
- **SettingsAccess** is also backwards compatible from macOS 11 Big Sur and later. Calling `openSettings()` will use the correct method to open the Settings scene for each supported operating system automatically.
- No private API is used, so it is safe for the Mac App Store.

Expand All @@ -33,6 +33,8 @@ Also, due to how SwiftUI `Button` works, it is impossible to attach a simultaneo

**SettingsAccess** uses a custom `Button` style which, when applied directly to `SettingsLink`, allows us to capture the `Button` press action and export a wrapper method as an environment method called `openSettings` that we can use.

The same button style is usable on `SettingsLink` directly in order to run code before and/or after the `Settings` scene is opened.

More info and a deep-dive can be found in [this reddit post](https://www.reddit.com/r/SwiftUI/comments/16ibgy3/settingslink_on_macos_14_why_it_sucks_and_how_i/).

## Using the Package
Expand Down Expand Up @@ -75,7 +77,8 @@ Add SettingsAccess as a dependency using Swift Package Manager.
}
```

3. In any subview where needed, add the environment method declaration. Then the Settings scene may be opened programmatically by calling this method.
3. In any subview where needed, add the environment method declaration. Then the Settings scene may be opened
programmatically by calling this method.

```swift
struct ContentView: View {
Expand All @@ -87,6 +90,27 @@ Add SettingsAccess as a dependency using Swift Package Manager.
}
```

4. If using a menu-based MenuBarExtra, do not apply `.openSettingsAccess()` to the enclosed menu.
`openSettings()` cannot be used here due to limitations of SwiftUI limitations. Instead, use the custom
`SettingsLink` initializer to run code before and/or after the menu item opens the `Settings` scene if desired.

```swift
@main
struct MyApp: App {
var body: some Scene {
MenuBarExtra {
SettingsLink {
Text("Settings...")
} preAction: {
// code to run before Settings opens
} postAction: {
// code to run after Settings opens
}
}
}
}
```

## Example Code

Try the [Demo](Demo) example project to see the library in action.
Expand All @@ -97,10 +121,6 @@ Requires Xcode 15.0 or higher.

Supports macOS 11.0 or higher.

## Known Issues

- It is not possible to call `openSettings()` from within a menu-based MenuBarExtra due to how SwiftUI builds menus. See [this thread](https://github.com/orchetect/SettingsAccess/issues/2) for details.

## Author

Coded by a bunch of 🐹 hamsters in a trenchcoat that calls itself [@orchetect](https://github.com/orchetect).
Expand Down
65 changes: 39 additions & 26 deletions Sources/SettingsAccess/PrePostActionsButtonStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,36 @@ import SwiftUI

/// Allows execution of code before and/or after user clicks a button.
/// Also provides a binding to a method which can programmatically call the button's action.
///
/// > Note that this will not work when applied to a `Button` or `SettingsLink` contained
/// > within a menu or menu-based MenuBarExtra.
@available(macOS 10.15, *)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
public struct PrePostActionsButtonStyle: PrimitiveButtonStyle {
public let preTapAction: (() -> Void)?
public let postTapAction: (() -> Void)?
public let preAction: (() -> Void)?
public let postAction: (() -> Void)?
@Binding public var performAction: () -> Void

/// Initialize with an optional pre-action and post-action. Also optionally supply a binding to
/// expose a method to programmatically call the button's action.
///
/// - Parameters:
/// - preTapAction: Closure to execute before the button's action.
/// - postTapAction: Closure to execute after the button's action.
/// - preAction: Closure to execute before the button's action.
/// - postAction: Closure to execute after the button's action.
/// - performAction: Binding to expose a method to programmatically call the button's action.
public init(
preTapAction: (() -> Void)?,
postTapAction: (() -> Void)?,
preAction: (() -> Void)?,
postAction: (() -> Void)?,
performAction: Binding<(() -> Void)> = .constant { }
) {
self.preTapAction = preTapAction
self.postTapAction = postTapAction
self.preAction = preAction
self.postAction = postAction
self._performAction = performAction
}

// note: this never gets called when used in a menu instead of a View
public func makeBody(configuration: Configuration) -> some View {
// capture the button action
DispatchQueue.main.async {
Expand All @@ -45,14 +49,23 @@ public struct PrePostActionsButtonStyle: PrimitiveButtonStyle {
}
}

return configuration.label
.contentShape(Rectangle())
.allowsHitTesting(true)
.onTapGesture {
preTapAction?()
if #available(macOS 12.0, *) { // role is macOS 12+
return Button(role: configuration.role) {
preAction?()
configuration.trigger()
postTapAction?()
postAction?()
} label: {
configuration.label
}
} else {
return Button {
preAction?()
configuration.trigger()
postAction?()
} label: {
configuration.label
}
}
}
}

Expand All @@ -61,25 +74,25 @@ public struct PrePostActionsButtonStyle: PrimitiveButtonStyle {
@available(tvOS, unavailable)
@available(watchOS, unavailable)
private struct PrePostActionsButtonStyleModifier: ViewModifier {
let preTapAction: (() -> Void)?
let postTapAction: (() -> Void)?
let preAction: (() -> Void)?
let postAction: (() -> Void)?
@Binding var performAction: () -> Void

init(
preTapAction: (() -> Void)?,
postTapAction: (() -> Void)?,
preAction: (() -> Void)?,
postAction: (() -> Void)?,
performAction: Binding<(() -> Void)> = .constant { }
) {
self.preTapAction = preTapAction
self.postTapAction = postTapAction
self.preAction = preAction
self.postAction = postAction
self._performAction = performAction
}

func body(content: Content) -> some View {
content.buttonStyle(
PrePostActionsButtonStyle(
preTapAction: preTapAction,
postTapAction: postTapAction,
preAction: preAction,
postAction: postAction,
performAction: $performAction
)
)
Expand All @@ -95,14 +108,14 @@ extension View {
/// Allows execution of code before and/or after user clicks a button.
/// Also provides a binding to a method which can programmatically call the button's action.
public func prePostActionsButtonStyle(
preTapAction: (() -> Void)? = nil,
postTapAction: (() -> Void)? = nil,
preAction: (() -> Void)? = nil,
postAction: (() -> Void)? = nil,
performAction: Binding<(() -> Void)> = .constant { }
) -> some View {
modifier(
PrePostActionsButtonStyleModifier(
preTapAction: preTapAction,
postTapAction: postTapAction,
preAction: preAction,
postAction: postAction,
performAction: performAction)
)
}
Expand Down
48 changes: 48 additions & 0 deletions Sources/SettingsAccess/SettingsLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// SettingsLink.swift
// SettingsAccess • https://github.com/orchetect/SettingsAccess
// © 2023 Steffan Andrews • Licensed under MIT License
//

#if os(macOS)

import Foundation
import SwiftUI

/// Create a `SettingsLink` that optionally executes code before or after the Settings scene is opened.
/// This is suitable for use in a menu, a menu-based `MenuBarExtra`, or any standard `View` where a button
/// is needed. This is also backwards-compatible to macOS 11.
///
/// - Parameters:
/// - label: A view to use as the label for this settings link.
/// - preAction: Closure to execute before the button's action.
/// - postAction: Closure to execute after the button's action.
@available(macOS 10.15, *)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@ViewBuilder
public func SettingsLink<Label: View>(
@ViewBuilder label: () -> Label,
preAction: @escaping () -> Void,
postAction: @escaping () -> Void
) -> some View {
if #available(macOS 14.0, *) {
SettingsLink(label: label)
.prePostActionsButtonStyle(
preAction: preAction,
postAction: postAction
)
} else {
Button(
action: {
preAction()
openSettingsLegacyOS()
postAction()
},
label: label
)
}
}

#endif

0 comments on commit dbd2726

Please sign in to comment.