Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ Furthermore, there is a `.floating` style, which will **automatically** be used

<img src="media/demo-floating.gif" width="50%"/>

## Hybrid Mode

DynamicNotchKit supports a "hybrid" layout where compact indicators (leading/trailing) remain visible alongside the expanded content. This is useful for showing status icons or controls while displaying detailed information.

```swift
let notch = DynamicNotch(
showCompactContentInExpandedMode: true
) {
Text("Expanded content here")
} compactLeading: {
Image(systemName: "waveform")
.foregroundStyle(.green)
} compactTrailing: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
}
await notch.expand()
```

### Floating Style Behavior

On Macs without a notch (using the `.floating` style), calling `compact()` will automatically enable hybrid mode and expand the window, showing compact indicators alongside the expanded content. This provides a consistent UX across all Mac models, ensuring your compact indicators are displayed when requested, regardless of hardware.

This is only a basic glimpse into this framework's capabilities. Documentation is available for **all** public methods and properties, so I encourage you to take a look at it for more advanced usage. Alternatively, you can take a look at the unit tests for this package, where I have added some usage examples as well.

Feel free to ask questions/report issues in the Issues tab!
Expand Down
6 changes: 6 additions & 0 deletions Sources/DynamicNotchKit/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ DynamicNotchKit provides a set of tools to help you integrate your macOS app wit

Unfortunately, a limitation (much like iOS), is that not all devices have this notch. Lucky for you, DynamicNotchKit is designed to be flexible and can adapt to different screen types and sizes, and provides a floating window style as backup. This ensures that your app looks great on _all_ devices.

## Hybrid Mode

DynamicNotchKit supports a "hybrid" layout where compact indicators remain visible alongside expanded content. Enable this by setting `showCompactContentInExpandedMode: true` when creating a ``DynamicNotch``.

On Macs without a physical notch (floating style), calling `compact()` automatically enables hybrid mode and expands the window, showing your compact indicators alongside the expanded content. This ensures a consistent experience across all Mac hardware.

## The Vision

There are _many_, _**many**_ macOS apps that attempt to add functionality to the notch. Unfortunately, what a lot of them do is to attempt to put *too* much functionality into such a small popover. The goal for DynamicNotchKit is not to replace the main app window, but to provide a simple and elegant way to display notifications and updates in a way that feels native to the platform, similar to iOS's Dynamic Island.
236 changes: 164 additions & 72 deletions Sources/DynamicNotchKit/DynamicNotch/DynamicNotch.swift

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions Sources/DynamicNotchKit/DynamicNotch/DynamicNotchStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,29 @@ public enum DynamicNotchStyle: Sendable {
}
}

/// Internal constant used by all animation properties to ensure consistency.
private static let standardAnimationDuration: TimeInterval = 0.4

var openingAnimation: Animation {
if isNotch {
.bouncy(duration: 0.4)
.bouncy(duration: Self.standardAnimationDuration)
} else {
.snappy(duration: 0.4)
.snappy(duration: Self.standardAnimationDuration)
}
}

var closingAnimation: Animation {
.smooth(duration: 0.4)
.smooth(duration: Self.standardAnimationDuration)
}

var conversionAnimation: Animation {
.snappy(duration: 0.4)
.snappy(duration: Self.standardAnimationDuration)
}

/// The duration of animations in seconds.
/// Used internally to coordinate async operations with animation completion.
var animationDuration: TimeInterval {
Self.standardAnimationDuration
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ extension DynamicNotchInfo {
self.dynamicNotch = dynamicNotch
}

public var body: some View {
var body: some View {
let internalNotch = dynamicNotch.internalDynamicNotch!
// In hybrid mode, both compact and expanded views are visible simultaneously.
// Disable matchedGeometryEffect to avoid animation conflicts between duplicate icons.
let useMatchedGeometry = !internalNotch.isHybridModeEnabled && dynamicNotch.shouldSkipHideWhenConverting

dynamicNotch.compactLeading
.transition(.blur(intensity: 10).combined(with: .scale(scale: 0.8)).combined(with: .opacity))
.matchedGeometryEffect(
id: "info_icon",
in: dynamicNotch.internalDynamicNotch.namespace ?? namespace,
isSource: dynamicNotch.internalDynamicNotch.state == .compact && dynamicNotch.shouldSkipHideWhenConverting
in: internalNotch.namespace ?? namespace,
properties: useMatchedGeometry ? .frame : [],
isSource: internalNotch.state == .compact && useMatchedGeometry
)
}
}
Expand All @@ -34,7 +40,7 @@ extension DynamicNotchInfo {
self.dynamicNotch = dynamicNotch
}

public var body: some View {
var body: some View {
dynamicNotch.compactTrailing
.transition(.blur(intensity: 10).combined(with: .scale(scale: 0.8)).combined(with: .opacity))
}
Expand All @@ -49,14 +55,19 @@ extension DynamicNotchInfo {
self.dynamicNotch = dynamicNotch
}

public var body: some View {
var body: some View {
let internalNotch = dynamicNotch.internalDynamicNotch!
// In hybrid mode, disable matchedGeometryEffect to avoid conflicts with the compact icon.
let useMatchedGeometry = !internalNotch.isHybridModeEnabled && dynamicNotch.shouldSkipHideWhenConverting

HStack(spacing: 10) {
if let icon = dynamicNotch.icon {
icon
.matchedGeometryEffect(
id: "info_icon",
in: dynamicNotch.internalDynamicNotch.namespace ?? namespace,
isSource: dynamicNotch.internalDynamicNotch.state == .expanded && dynamicNotch.shouldSkipHideWhenConverting
in: internalNotch.namespace ?? namespace,
properties: useMatchedGeometry ? .frame : [],
isSource: internalNotch.state == .expanded && useMatchedGeometry
)
}

Expand Down
34 changes: 22 additions & 12 deletions Sources/DynamicNotchKit/DynamicNotchInfo/DynamicNotchInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Kai Azim on 2023-08-25.
//

import Combine
import SwiftUI

// MARK: DynamicNotchInfo
Expand Down Expand Up @@ -46,13 +47,18 @@ import SwiftUI
///
public final class DynamicNotchInfo: ObservableObject, DynamicNotchControllable {
var internalDynamicNotch: DynamicNotch<InfoView, CompactLeadingView, CompactTrailingView>!
private var cancellable: AnyCancellable?

@Published public var icon: DynamicNotchInfo.Label?
@Published public var title: LocalizedStringKey
@Published public var description: LocalizedStringKey?
@Published public var textColor: Color?
@Published public var compactLeading: DynamicNotchInfo.Label? {
didSet { internalDynamicNotch.disableCompactLeading = compactLeading == nil }
didSet {
internalDynamicNotch.disableCompactLeading = compactLeading == nil
// Reset flag when user explicitly sets a different compact leading icon
shouldSkipHideWhenConverting = false
}
}

@Published public var compactTrailing: DynamicNotchInfo.Label? {
Expand All @@ -68,6 +74,7 @@ public final class DynamicNotchInfo: ObservableObject, DynamicNotchControllable
/// - description: the description to display in the expanded state of the notch. If unspecified, no description will be displayed.
/// - compactLeading: the icon to display in the compact leading state of the notch. If unspecified, the expanded icon will be displayed.
/// - compactTrailing: the icon to display in the compact trailing state of the notch. If unspecified, no icon will be displayed.
/// - showCompactContentInExpandedMode: when `true`, compact indicators remain visible alongside expanded content. Defaults to `false` for traditional mutually-exclusive states.
/// - hoverBehavior: the hover behavior of the notch, which allows for different interactions such as haptic feedback, increased shadow etc.
/// - style: the popover's style. If unspecified, the style will be automatically set according to the screen (notch or floating).
public init(
Expand All @@ -76,6 +83,7 @@ public final class DynamicNotchInfo: ObservableObject, DynamicNotchControllable
description: LocalizedStringKey? = nil,
compactLeading: DynamicNotchInfo.Label? = nil,
compactTrailing: DynamicNotchInfo.Label? = nil,
showCompactContentInExpandedMode: Bool = false,
hoverBehavior: DynamicNotchHoverBehavior = .all,
style: DynamicNotchStyle = .auto
) {
Expand All @@ -84,14 +92,22 @@ public final class DynamicNotchInfo: ObservableObject, DynamicNotchControllable
self.description = description
self.internalDynamicNotch = DynamicNotch(
hoverBehavior: hoverBehavior,
style: style
style: style,
showCompactContentInExpandedMode: showCompactContentInExpandedMode
) {
InfoView(dynamicNotch: self)
} compactLeading: {
CompactLeadingView(dynamicNotch: self)
} compactTrailing: {
CompactTrailingView(dynamicNotch: self)
}

// Forward objectWillChange from internalDynamicNotch so views observing
// DynamicNotchInfo also update when internal state (like isHybridModeEnabled) changes.
self.cancellable = internalDynamicNotch.objectWillChange.sink { [weak self] _ in
self?.objectWillChange.send()
}

if let compactLeading {
self.compactLeading = compactLeading
} else {
Expand All @@ -102,21 +118,15 @@ public final class DynamicNotchInfo: ObservableObject, DynamicNotchControllable
}

public func expand(
on screen: NSScreen = NSScreen.screens[0]
on screen: NSScreen? = nil
) async {
await internalDynamicNotch._expand(
on: screen,
skipHide: shouldSkipHideWhenConverting
)
await internalDynamicNotch.expand(on: screen)
}

public func compact(
on screen: NSScreen = NSScreen.screens[0]
on screen: NSScreen? = nil
) async {
await internalDynamicNotch._compact(
on: screen,
skipHide: shouldSkipHideWhenConverting
)
await internalDynamicNotch.compact(on: screen)
}

public func hide() async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ public protocol DynamicNotchControllable {
///
/// This method is asynchronous and waits for the animation to complete before returning.
///
/// - Parameter screen: the screen on which to show the expanded notch.
func expand(on screen: NSScreen) async
/// - Parameter screen: the screen on which to show the expanded notch. If nil, defaults to NSScreen.main or the first available screen.
func expand(on screen: NSScreen?) async

/// Sets the notch's appearance to be compact, showing the leading and trailing contents.
///
/// This method is asynchronous and waits for the animation to complete before returning.
///
/// - Parameter screen: the screen on which to show the compact notch.
func compact(on screen: NSScreen) async
/// - Parameter screen: the screen on which to show the compact notch. If nil, defaults to NSScreen.main or the first available screen.
func compact(on screen: NSScreen?) async

/// Sets the notch's appearance to be hidden, hiding all content and deinitializing the window.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ enum DynamicNotchSection {
case expanded
case compactLeading
case compactTrailing
case compactCenter
}
2 changes: 1 addition & 1 deletion Sources/DynamicNotchKit/Utility/NSScreen+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import SwiftUI

extension NSScreen {
public extension NSScreen {
static var screenWithMouse: NSScreen? {
let mouseLocation = NSEvent.mouseLocation
let screens = NSScreen.screens
Expand Down
Loading