From df5c96ae7defbb0e9ad878b6e4ce45c779ae1951 Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:18:40 +0100 Subject: [PATCH 01/10] Rename TopologyButtonsView to CutoutAccessoryView Renamed TopologyButtonsView to CutoutAccessoryView throughout the codebase for clarity and consistency. Updated all usages, documentation, and previews. Also removed the redundant NotchMyProblemClass.swift file, consolidating its contents into NotchMyProblem.swift. --- Examples/NotchMyProblemDemo/ContentView.swift | 6 +- README.md | 16 +- ...nsView.swift => CutoutAccessoryView.swift} | 96 ++--- Sources/NotchMyProblem/NotchMyProblem.swift | 393 +++++++++++++++++- .../NotchMyProblem/NotchMyProblemClass.swift | 392 ----------------- 5 files changed, 446 insertions(+), 457 deletions(-) rename Sources/NotchMyProblem/{TopologyButtonsView.swift => CutoutAccessoryView.swift} (58%) delete mode 100644 Sources/NotchMyProblem/NotchMyProblemClass.swift diff --git a/Examples/NotchMyProblemDemo/ContentView.swift b/Examples/NotchMyProblemDemo/ContentView.swift index 51ea81e..ab23021 100644 --- a/Examples/NotchMyProblemDemo/ContentView.swift +++ b/Examples/NotchMyProblemDemo/ContentView.swift @@ -74,14 +74,14 @@ struct ContentView: View { .padding() // Buttons positioned around the notch/island - TopologyButtonsView( - leadingButton: { + CutoutAccessoryView( + leadingContent: { Button(action: { }) { Image(systemName: "arrow.left") .modifier(ButtonStyleModifier()) } }, - trailingButton: { + trailingContent: { Button(action: { }) { Text("Done") .modifier(ButtonStyleModifier()) diff --git a/README.md b/README.md index bd23efc..e506e19 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ NotchMyProblem automatically detects the device type and adjusts the UI accordin ## **Basic Usage** -### TopologyButtonsView +### CutoutAccessoryView -The simplest way to use NotchMyProblem is with the included `TopologyButtonsView`: +The simplest way to use NotchMyProblem is with the included `CutoutAccessoryView`: ```swift import SwiftUI @@ -48,13 +48,13 @@ struct MyView: View { // Your main content here // Buttons positioned around the notch/island - TopologyButtonsView( - leadingButton: { + CutoutAccessoryView( + leadingContent: { Button(action: { print("Left button tapped") }) { Image(systemName: "gear") } }, - trailingButton: { + trailingContent: { Button(action: { print("Right button tapped") }) { Text("Save") } @@ -104,9 +104,9 @@ NotchMyProblem.shared.overrides = [ #### 3. View-Specific Overrides (using SwiftUI modifiers) ```swift -TopologyButtonsView( - leadingButton: { /* ... */ }, - trailingButton: { /* ... */ } +CutoutAccessoryView( + leadingContent: { /* ... */ }, + trailingContent: { /* ... */ } ) .notchOverride(.series(prefix: "iPhone14", scale: 0.6, heightFactor: 0.6)) ``` diff --git a/Sources/NotchMyProblem/TopologyButtonsView.swift b/Sources/NotchMyProblem/CutoutAccessoryView.swift similarity index 58% rename from Sources/NotchMyProblem/TopologyButtonsView.swift rename to Sources/NotchMyProblem/CutoutAccessoryView.swift index 41b1987..12609ba 100644 --- a/Sources/NotchMyProblem/TopologyButtonsView.swift +++ b/Sources/NotchMyProblem/CutoutAccessoryView.swift @@ -1,5 +1,5 @@ // -// TopologyButtonsView.swift +// CutoutAccessoryView.swift // NotchMyProblem // // Created by Aether on 03/03/2025. @@ -7,29 +7,36 @@ import SwiftUI -/// A view that positions buttons around the physical topology of the device's top area, +/// A view that positions content around the physical topology of the device's top area, /// adapting to notches, Dynamic Islands, and other screen cutouts automatically. +/// +/// You can provide any views for the leading and trailing sides. +/// - leadingContent: The view to display on the left side +/// - trailingContent: The view to display on the right side @available(iOS 13.0, *) -public struct TopologyButtonsView: View { - // The button that appears on the left/leading side - let leadingButton: LeadingButton - - // The button that appears on the right/trailing side - let trailingButton: TrailingButton - +public struct CutoutAccessoryView: View { // Environment access to any custom overrides @Environment(\.notchOverrides) private var environmentOverrides - - /// Creates a new TopologyButtonsView with custom leading and trailing buttons + + // The view that appears on the left/leading side + let leadingContent: LeadingContent + + // The view that appears on the right/trailing side + let trailingContent: TrailingContent + + // Access class + let notchMyProblem = NotchMyProblem.self + + /// Creates a new CutoutAccessoryView with custom leading and trailing content. /// - Parameters: - /// - leadingButton: The button to display on the left side - /// - trailingButton: The button to display on the right side + /// - leadingContent: The view to display on the left side. + /// - trailingContent: The view to display on the right side. public init( - @ViewBuilder leadingButton: () -> LeadingButton, - @ViewBuilder trailingButton: () -> TrailingButton + @ViewBuilder leadingContent: () -> LeadingContent, + @ViewBuilder trailingContent: () -> TrailingContent ) { - self.leadingButton = leadingButton() - self.trailingButton = trailingButton() + self.leadingContent = leadingContent() + self.trailingContent = trailingContent() } public var body: some View { @@ -39,11 +46,8 @@ public struct TopologyButtonsView: Vi let hasTopCutout = statusBarHeight > 40 HStack(spacing: 0) { - // Leading button with appropriate alignment - leadingButton + leadingContent .frame(maxWidth: .infinity, alignment: hasTopCutout ? .center : .leading) - - .padding(7) // Space for the device's top cutout if present if hasTopCutout, let exclusionWidth = getAdjustedExclusionRect()?.width, exclusionWidth > 0 { @@ -51,16 +55,14 @@ public struct TopologyButtonsView: Vi .frame(width: exclusionWidth) } - // Trailing button with appropriate alignment - trailingButton + trailingContent .frame(maxWidth: .infinity, alignment: hasTopCutout ? .center : .trailing) - .padding(7) +// .padding(7) } // Adjust height based on device topology - .frame(height: hasTopCutout ? statusBarHeight + 4 : 40) - .padding(.top, hasTopCutout ? 0 : 5) + .frame(height: hasTopCutout ? notchMyProblem.exclusionRect?.height ?? statusBarHeight : 40) + .padding(.top, notchMyProblem.exclusionRect?.minY ?? (hasTopCutout ? 0 : 5)) .edgesIgnoringSafeArea(.all) - .padding(.horizontal, 15) } } @@ -68,34 +70,24 @@ public struct TopologyButtonsView: Vi private func getAdjustedExclusionRect() -> CGRect? { if let overrides = environmentOverrides { // Use environment-specific overrides if available - let rect = NotchMyProblem.shared.adjustedExclusionRect(using: overrides) + let rect = notchMyProblem.shared.adjustedExclusionRect(using: overrides) return rect } else { // Otherwise use the instance's configured overrides - let rect = NotchMyProblem.shared.adjustedExclusionRect + let rect = notchMyProblem.shared.adjustedExclusionRect return rect } } } #Preview { - // Default TopologyButtonsView - TopologyButtonsView( - leadingButton: { - Button(action: { - print("Default: Back tapped") - }) { - Image(systemName: "chevron.left") - .font(.headline) - } + // Default CutoutAccessoryView + CutoutAccessoryView( + leadingContent: { + Color.red }, - trailingButton: { - Button(action: { - print("Default: Save tapped") - }) { - Text("Save") - .font(.headline) - } + trailingContent: { + Color.red } ) .previewDisplayName("Default") @@ -105,9 +97,9 @@ public struct TopologyButtonsView: Vi #Preview { - // TopologyButtonsView with view-specific override - TopologyButtonsView( - leadingButton: { + // CutoutAccessoryView with view-specific override + CutoutAccessoryView( + leadingContent: { Button(action: { print("Override: Back tapped") }) { @@ -115,7 +107,7 @@ public struct TopologyButtonsView: Vi .font(.headline) } }, - trailingButton: { + trailingContent: { Button(action: { print("Override: Save tapped") }) { @@ -130,8 +122,8 @@ public struct TopologyButtonsView: Vi #Preview{ // Another variant with different styling - TopologyButtonsView( - leadingButton: { + CutoutAccessoryView( + leadingContent: { Button(action: { print("Styled: Cancel tapped") }) { @@ -140,7 +132,7 @@ public struct TopologyButtonsView: Vi .foregroundColor(.red) } }, - trailingButton: { + trailingContent: { Button(action: { print("Styled: Confirm tapped") }) { diff --git a/Sources/NotchMyProblem/NotchMyProblem.swift b/Sources/NotchMyProblem/NotchMyProblem.swift index 28415ef..9cb971e 100644 --- a/Sources/NotchMyProblem/NotchMyProblem.swift +++ b/Sources/NotchMyProblem/NotchMyProblem.swift @@ -1,3 +1,392 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book +// +// Classes.swift +// NotchMyProblem +// +// Created by Aether on 03/03/2025. +// +import SwiftUI +import os +import UIKit + +/// Extension to safely access the exclusion area (notch/Dynamic Island) +extension UIScreen { + /// Returns the frame of the Dynamic Island or notch. + var exclusionArea: CGRect? { + // Early return for devices known not to have a notch/island + // Check if the device is an iPhone and if it has a notch based on model + let modelId = UIDevice.modelIdentifier + let isNotchedDevice = modelId.hasPrefix("iPhone") && + !["iPhone8", "iPhone9", "iPhone10,4", "iPhone10,5"].contains { modelId.hasPrefix($0) } + + if !isNotchedDevice { + return nil + } + + let areaExclusionSelector = { + let selectorName = ["Area", "exclusion", "_"].reversed().joined() + return NSSelectorFromString(selectorName) + }() + + // Check if the method exists before trying to call it + guard self.responds(to: areaExclusionSelector) else { + return nil + } + + // Safely get the exclusion area object + let areaExclusionMethod = { + let implementation = self.method(for: areaExclusionSelector) + let methodType = (@convention(c) (AnyObject, Selector) -> AnyObject?).self + return unsafeBitCast(implementation, to: methodType) + }() + + let object = areaExclusionMethod(self, areaExclusionSelector) + + // Check if the object exists and responds to the rect selector + let rectSelector = NSSelectorFromString("rect") + guard let object, object.responds(to: rectSelector) else { + return nil + } + + let rectMethod = { + let implementation = object.method(for: rectSelector) + let methodType = (@convention(c) (AnyObject, Selector) -> CGRect).self + return unsafeBitCast(implementation, to: methodType) + }() + + let rect = rectMethod(object, rectSelector) + + // Validate the rect before returning it + if rect.width <= 0 || rect.height <= 0 || rect.isInfinite || rect.isNull { + return nil + } + + return rect + } +} + + +/// Logging helper that works across iOS versions +@available(iOS 13.0, *) +struct NMPLogger { + private let subsystem = "com.notchmyproblem" + private let category: String + + #if os(iOS) + @available(iOS 14.0, *) + private var logger: Logger { + Logger(subsystem: subsystem, category: category) + } + + private var osLog: OSLog { + OSLog(subsystem: subsystem, category: category) + } + #endif + + init(category: String) { + self.category = category + } + + func debug(_ message: String) { + #if os(iOS) + if #available(iOS 14.0, *) { + logger.debug("\(message)") + } else { + os_log("%{public}@", log: osLog, type: .debug, message) + } + #endif + } + + func info(_ message: String) { + #if os(iOS) + if #available(iOS 14.0, *) { + logger.info("\(message)") + } else { + os_log("%{public}@", log: osLog, type: .info, message) + } + #endif + } + + func notice(_ message: String) { + #if os(iOS) + if #available(iOS 14.0, *) { + logger.notice("\(message)") + } else { + os_log("%{public}@", log: osLog, type: .default, message) + } + #endif + } + + func error(_ message: String) { + #if os(iOS) + if #available(iOS 14.0, *) { + logger.error("\(message)") + } else { + os_log("%{public}@", log: osLog, type: .error, message) + } + #endif + } +} + +/// Configuration for a device-specific notch/island adjustment +public struct DeviceOverride: Equatable, Hashable, Sendable { + /// The device model identifier or prefix to match + public let modelIdentifier: String + + /// Scale factor to apply to the width (1.0 = original width) + public let scale: CGFloat + + /// Factor to apply to the height (1.0 = original height) + public let heightFactor: CGFloat + + /// Corner radius (if needed for visualization) + public let radius: CGFloat + + /// Whether this is an exact match or a prefix match + public let isExactMatch: Bool + + /// Creates a new device override with the specified parameters + /// - Parameters: + /// - modelIdentifier: The device model to match (e.g., "iPhone14,3") + /// - scale: Width scale factor (default: 1.0) + /// - heightFactor: Height scale factor (default: 1.0) + /// - radius: Corner radius (default: 0) + /// - isExactMatch: Whether to match the exact model or use as prefix (default: true) + public init( + modelIdentifier: String, + scale: CGFloat = 1.0, + heightFactor: CGFloat = 1.0, + radius: CGFloat = 0, + isExactMatch: Bool = true + ) { + self.modelIdentifier = modelIdentifier + self.scale = scale + self.heightFactor = heightFactor + self.radius = radius + self.isExactMatch = isExactMatch + } + + /// Creates a series override that matches any device whose model ID starts with the prefix + /// - Parameters: + /// - seriesPrefix: The device series prefix (e.g., "iPhone14") + /// - scale: Width scale factor + /// - heightFactor: Height scale factor + /// - radius: Corner radius + public static func series( + prefix: String, + scale: CGFloat, + heightFactor: CGFloat, + radius: CGFloat = 0 + ) -> DeviceOverride { + DeviceOverride( + modelIdentifier: prefix, + scale: scale, + heightFactor: heightFactor, + radius: radius, + isExactMatch: false + ) + } +} + +/// Manages the detection and adjustment of the iPhone's top notch/Dynamic Island area +@available(iOS 13.0, *) +@MainActor +public final class NotchMyProblem: Sendable, ObservableObject { + + // MARK: - Logging + + /// Logger for NotchMyProblem class + private static let logger = NMPLogger(category: "NotchMyProblem") + + // MARK: - Singleton + + /// Shared instance for app-wide access + public static let shared = NotchMyProblem() + + // MARK: - Properties + + /// Current device model identifier + let modelId = UIDevice.modelIdentifier + + // MARK: - Notch Detection + + /// The raw notch/Dynamic Island area retrieved via private API + /// Uses a safer approach to access private APIs + public static let exclusionRect: CGRect? = { + if let rect = UIScreen.main.exclusionArea { + logger.info("Found notch for \(UIDevice.modelIdentifier): \(rect)") + return rect + } + + logger.notice("No notch found, returning .zero") + return nil + }() + + // MARK: - Device-specific Adjustments + + /// Global overrides that apply to all instances + public static var globalOverrides: [DeviceOverride] = [ + .series(prefix: "iPhone13", scale: 0.95, heightFactor: 1.0, radius: 27), // iPhone 12 series + .series(prefix: "iPhone14", scale: 0.75, heightFactor: 0.75, radius: 24) // iPhone 13/14 series + ] + + /// Instance-specific overrides that only apply to this instance + @Published public var overrides: [DeviceOverride] = [] + + // MARK: - Initialization + + /// Private initializer to enforce singleton pattern + private init() { + // This class is isolated to the main actor since it interacts with UIKit + } + + // MARK: - Public API + + /// Returns the notch/island area with device-specific adjustments applied + /// Use this for proper UI positioning around the top cutout + public var adjustedExclusionRect: CGRect? { + adjustedExclusionRect(using: overrides) + } + + /// Returns the notch/island area with the specified overrides applied + /// - Parameter customOverrides: Custom overrides to use for this specific calculation + /// - Returns: The adjusted exclusion rect + public func adjustedExclusionRect(using customOverrides: [DeviceOverride]? = nil) -> CGRect? { + let baseRect = NotchMyProblem.exclusionRect + + guard let baseRect else { return nil } + + // Determine which overrides to use (in order of precedence) + let effectiveOverrides = customOverrides ?? overrides + + // Try instance overrides first (exact matches) + for override in effectiveOverrides where override.isExactMatch && override.modelIdentifier == modelId { + let adjusted = applyOverride(to: baseRect, with: override) + NotchMyProblem.logger.debug("Applied instance exact override for \(self.modelId): \(adjusted)") + return adjusted + } + + // Then try instance overrides (prefix matches) + for override in effectiveOverrides where !override.isExactMatch && modelId.hasPrefix(override.modelIdentifier) { + let adjusted = applyOverride(to: baseRect, with: override) + NotchMyProblem.logger.debug("Applied instance series override \(override.modelIdentifier) for \(self.modelId): \(adjusted)") + return adjusted + } + + // Then try global overrides (exact matches) + for override in NotchMyProblem.globalOverrides where override.isExactMatch && override.modelIdentifier == modelId { + let adjusted = applyOverride(to: baseRect, with: override) + NotchMyProblem.logger.debug("Applied global exact override for \(self.modelId): \(adjusted)") + return adjusted + } + + // Finally try global overrides (prefix matches) + for override in NotchMyProblem.globalOverrides where !override.isExactMatch && modelId.hasPrefix(override.modelIdentifier) { + let adjusted = applyOverride(to: baseRect, with: override) + NotchMyProblem.logger.debug("Applied global series override \(override.modelIdentifier) for \(self.modelId): \(adjusted)") + return adjusted + } + + // When in doubt, use what we found + NotchMyProblem.logger.debug("No overrides applied for \(self.modelId)") + return baseRect + } + + // MARK: - Private Helpers + + /// Applies the specified override parameters to adjust the notch rect + private func applyOverride(to rect: CGRect, with override: DeviceOverride) -> CGRect { + // Scale the width + let scaledWidth = rect.width * override.scale + + // Adjust the height + let scaledHeight = rect.height * override.heightFactor + + // Keep it centered + let originX = rect.origin.x + (rect.width - scaledWidth) / 2 + + // Build the adjusted rect + return CGRect(x: originX, y: rect.origin.y, width: scaledWidth, height: scaledHeight) + } +} + +/// Device identification utilities +extension UIDevice { + /// Logger for UIDevice extension + private static let logger = NMPLogger(category: "UIDeviceExtension") + + /// The device's model identifier (e.g., "iPhone14,4") + @MainActor + static let modelIdentifier: String = { + // Handle simulator case + if let simulatorModelIdentifier = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { + logger.debug("Running in simulator with model: \(simulatorModelIdentifier)") + return simulatorModelIdentifier + } + + // Get actual device identifier + var sysinfo = utsname() + uname(&sysinfo) + let machineData = Data(bytes: &sysinfo.machine, count: Int(_SYS_NAMELEN)) + let identifier = String(bytes: machineData, encoding: .ascii)? + .trimmingCharacters(in: .controlCharacters) ?? "unknown" + + logger.debug("Device model identified as: \(identifier)") + return identifier + }() +} + +/// SwiftUI view extension for applying custom notch overrides +@available(iOS 13.0, *) +public extension View { + /// Applies custom notch/island overrides to this view hierarchy + /// - Parameter overrides: The device overrides to apply + /// - Returns: A view with the specified notch overrides + func notchOverrides(_ overrides: [DeviceOverride]) -> some View { + modifier(NotchOverrideModifier(overrides: overrides)) + } + func notchOverrides(_ overrides: [DeviceOverride]?) -> some View { + modifier(NotchOverrideModifier(overrides: overrides.map { $0 } ?? [])) + } + + /// Applies a single custom notch/island override to this view hierarchy + /// - Parameter override: The device override to apply + /// - Returns: A view with the specified notch override + func notchOverride(_ override: DeviceOverride) -> some View { + notchOverrides([override]) + } + + /// Conditionally applies a custom notch/island override to this view hierarchy + /// - Parameter override: Optional device override to apply (nil means no override) + /// - Returns: A view with the specified notch override if provided + func notchOverride(_ override: DeviceOverride?) -> some View { + modifier(NotchOverrideModifier(overrides: override.map { [$0] } ?? [])) + } +} + +/// Environment key for notch overrides +@available(iOS 13.0, *) +private struct NotchOverridesKey: EnvironmentKey { + static let defaultValue: [DeviceOverride]? = nil +} + +/// Environment extension for notch overrides +@available(iOS 13.0, *) +public extension EnvironmentValues { + /// Custom notch overrides for the current environment + var notchOverrides: [DeviceOverride]? { + get { self[NotchOverridesKey.self] } + set { self[NotchOverridesKey.self] = newValue } + } +} + +/// View modifier for applying notch overrides +@available(iOS 13.0, *) +private struct NotchOverrideModifier: ViewModifier { + @ObservedObject private var notchManager = NotchMyProblem.shared + let overrides: [DeviceOverride] + + func body(content: Content) -> some View { + content.environment(\.notchOverrides, overrides) + } +} diff --git a/Sources/NotchMyProblem/NotchMyProblemClass.swift b/Sources/NotchMyProblem/NotchMyProblemClass.swift deleted file mode 100644 index 9cb971e..0000000 --- a/Sources/NotchMyProblem/NotchMyProblemClass.swift +++ /dev/null @@ -1,392 +0,0 @@ -// -// Classes.swift -// NotchMyProblem -// -// Created by Aether on 03/03/2025. -// - -import SwiftUI -import os -import UIKit - -/// Extension to safely access the exclusion area (notch/Dynamic Island) -extension UIScreen { - /// Returns the frame of the Dynamic Island or notch. - var exclusionArea: CGRect? { - // Early return for devices known not to have a notch/island - // Check if the device is an iPhone and if it has a notch based on model - let modelId = UIDevice.modelIdentifier - let isNotchedDevice = modelId.hasPrefix("iPhone") && - !["iPhone8", "iPhone9", "iPhone10,4", "iPhone10,5"].contains { modelId.hasPrefix($0) } - - if !isNotchedDevice { - return nil - } - - let areaExclusionSelector = { - let selectorName = ["Area", "exclusion", "_"].reversed().joined() - return NSSelectorFromString(selectorName) - }() - - // Check if the method exists before trying to call it - guard self.responds(to: areaExclusionSelector) else { - return nil - } - - // Safely get the exclusion area object - let areaExclusionMethod = { - let implementation = self.method(for: areaExclusionSelector) - let methodType = (@convention(c) (AnyObject, Selector) -> AnyObject?).self - return unsafeBitCast(implementation, to: methodType) - }() - - let object = areaExclusionMethod(self, areaExclusionSelector) - - // Check if the object exists and responds to the rect selector - let rectSelector = NSSelectorFromString("rect") - guard let object, object.responds(to: rectSelector) else { - return nil - } - - let rectMethod = { - let implementation = object.method(for: rectSelector) - let methodType = (@convention(c) (AnyObject, Selector) -> CGRect).self - return unsafeBitCast(implementation, to: methodType) - }() - - let rect = rectMethod(object, rectSelector) - - // Validate the rect before returning it - if rect.width <= 0 || rect.height <= 0 || rect.isInfinite || rect.isNull { - return nil - } - - return rect - } -} - - -/// Logging helper that works across iOS versions -@available(iOS 13.0, *) -struct NMPLogger { - private let subsystem = "com.notchmyproblem" - private let category: String - - #if os(iOS) - @available(iOS 14.0, *) - private var logger: Logger { - Logger(subsystem: subsystem, category: category) - } - - private var osLog: OSLog { - OSLog(subsystem: subsystem, category: category) - } - #endif - - init(category: String) { - self.category = category - } - - func debug(_ message: String) { - #if os(iOS) - if #available(iOS 14.0, *) { - logger.debug("\(message)") - } else { - os_log("%{public}@", log: osLog, type: .debug, message) - } - #endif - } - - func info(_ message: String) { - #if os(iOS) - if #available(iOS 14.0, *) { - logger.info("\(message)") - } else { - os_log("%{public}@", log: osLog, type: .info, message) - } - #endif - } - - func notice(_ message: String) { - #if os(iOS) - if #available(iOS 14.0, *) { - logger.notice("\(message)") - } else { - os_log("%{public}@", log: osLog, type: .default, message) - } - #endif - } - - func error(_ message: String) { - #if os(iOS) - if #available(iOS 14.0, *) { - logger.error("\(message)") - } else { - os_log("%{public}@", log: osLog, type: .error, message) - } - #endif - } -} - -/// Configuration for a device-specific notch/island adjustment -public struct DeviceOverride: Equatable, Hashable, Sendable { - /// The device model identifier or prefix to match - public let modelIdentifier: String - - /// Scale factor to apply to the width (1.0 = original width) - public let scale: CGFloat - - /// Factor to apply to the height (1.0 = original height) - public let heightFactor: CGFloat - - /// Corner radius (if needed for visualization) - public let radius: CGFloat - - /// Whether this is an exact match or a prefix match - public let isExactMatch: Bool - - /// Creates a new device override with the specified parameters - /// - Parameters: - /// - modelIdentifier: The device model to match (e.g., "iPhone14,3") - /// - scale: Width scale factor (default: 1.0) - /// - heightFactor: Height scale factor (default: 1.0) - /// - radius: Corner radius (default: 0) - /// - isExactMatch: Whether to match the exact model or use as prefix (default: true) - public init( - modelIdentifier: String, - scale: CGFloat = 1.0, - heightFactor: CGFloat = 1.0, - radius: CGFloat = 0, - isExactMatch: Bool = true - ) { - self.modelIdentifier = modelIdentifier - self.scale = scale - self.heightFactor = heightFactor - self.radius = radius - self.isExactMatch = isExactMatch - } - - /// Creates a series override that matches any device whose model ID starts with the prefix - /// - Parameters: - /// - seriesPrefix: The device series prefix (e.g., "iPhone14") - /// - scale: Width scale factor - /// - heightFactor: Height scale factor - /// - radius: Corner radius - public static func series( - prefix: String, - scale: CGFloat, - heightFactor: CGFloat, - radius: CGFloat = 0 - ) -> DeviceOverride { - DeviceOverride( - modelIdentifier: prefix, - scale: scale, - heightFactor: heightFactor, - radius: radius, - isExactMatch: false - ) - } -} - -/// Manages the detection and adjustment of the iPhone's top notch/Dynamic Island area -@available(iOS 13.0, *) -@MainActor -public final class NotchMyProblem: Sendable, ObservableObject { - - // MARK: - Logging - - /// Logger for NotchMyProblem class - private static let logger = NMPLogger(category: "NotchMyProblem") - - // MARK: - Singleton - - /// Shared instance for app-wide access - public static let shared = NotchMyProblem() - - // MARK: - Properties - - /// Current device model identifier - let modelId = UIDevice.modelIdentifier - - // MARK: - Notch Detection - - /// The raw notch/Dynamic Island area retrieved via private API - /// Uses a safer approach to access private APIs - public static let exclusionRect: CGRect? = { - if let rect = UIScreen.main.exclusionArea { - logger.info("Found notch for \(UIDevice.modelIdentifier): \(rect)") - return rect - } - - logger.notice("No notch found, returning .zero") - return nil - }() - - // MARK: - Device-specific Adjustments - - /// Global overrides that apply to all instances - public static var globalOverrides: [DeviceOverride] = [ - .series(prefix: "iPhone13", scale: 0.95, heightFactor: 1.0, radius: 27), // iPhone 12 series - .series(prefix: "iPhone14", scale: 0.75, heightFactor: 0.75, radius: 24) // iPhone 13/14 series - ] - - /// Instance-specific overrides that only apply to this instance - @Published public var overrides: [DeviceOverride] = [] - - // MARK: - Initialization - - /// Private initializer to enforce singleton pattern - private init() { - // This class is isolated to the main actor since it interacts with UIKit - } - - // MARK: - Public API - - /// Returns the notch/island area with device-specific adjustments applied - /// Use this for proper UI positioning around the top cutout - public var adjustedExclusionRect: CGRect? { - adjustedExclusionRect(using: overrides) - } - - /// Returns the notch/island area with the specified overrides applied - /// - Parameter customOverrides: Custom overrides to use for this specific calculation - /// - Returns: The adjusted exclusion rect - public func adjustedExclusionRect(using customOverrides: [DeviceOverride]? = nil) -> CGRect? { - let baseRect = NotchMyProblem.exclusionRect - - guard let baseRect else { return nil } - - // Determine which overrides to use (in order of precedence) - let effectiveOverrides = customOverrides ?? overrides - - // Try instance overrides first (exact matches) - for override in effectiveOverrides where override.isExactMatch && override.modelIdentifier == modelId { - let adjusted = applyOverride(to: baseRect, with: override) - NotchMyProblem.logger.debug("Applied instance exact override for \(self.modelId): \(adjusted)") - return adjusted - } - - // Then try instance overrides (prefix matches) - for override in effectiveOverrides where !override.isExactMatch && modelId.hasPrefix(override.modelIdentifier) { - let adjusted = applyOverride(to: baseRect, with: override) - NotchMyProblem.logger.debug("Applied instance series override \(override.modelIdentifier) for \(self.modelId): \(adjusted)") - return adjusted - } - - // Then try global overrides (exact matches) - for override in NotchMyProblem.globalOverrides where override.isExactMatch && override.modelIdentifier == modelId { - let adjusted = applyOverride(to: baseRect, with: override) - NotchMyProblem.logger.debug("Applied global exact override for \(self.modelId): \(adjusted)") - return adjusted - } - - // Finally try global overrides (prefix matches) - for override in NotchMyProblem.globalOverrides where !override.isExactMatch && modelId.hasPrefix(override.modelIdentifier) { - let adjusted = applyOverride(to: baseRect, with: override) - NotchMyProblem.logger.debug("Applied global series override \(override.modelIdentifier) for \(self.modelId): \(adjusted)") - return adjusted - } - - // When in doubt, use what we found - NotchMyProblem.logger.debug("No overrides applied for \(self.modelId)") - return baseRect - } - - // MARK: - Private Helpers - - /// Applies the specified override parameters to adjust the notch rect - private func applyOverride(to rect: CGRect, with override: DeviceOverride) -> CGRect { - // Scale the width - let scaledWidth = rect.width * override.scale - - // Adjust the height - let scaledHeight = rect.height * override.heightFactor - - // Keep it centered - let originX = rect.origin.x + (rect.width - scaledWidth) / 2 - - // Build the adjusted rect - return CGRect(x: originX, y: rect.origin.y, width: scaledWidth, height: scaledHeight) - } -} - -/// Device identification utilities -extension UIDevice { - /// Logger for UIDevice extension - private static let logger = NMPLogger(category: "UIDeviceExtension") - - /// The device's model identifier (e.g., "iPhone14,4") - @MainActor - static let modelIdentifier: String = { - // Handle simulator case - if let simulatorModelIdentifier = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { - logger.debug("Running in simulator with model: \(simulatorModelIdentifier)") - return simulatorModelIdentifier - } - - // Get actual device identifier - var sysinfo = utsname() - uname(&sysinfo) - let machineData = Data(bytes: &sysinfo.machine, count: Int(_SYS_NAMELEN)) - let identifier = String(bytes: machineData, encoding: .ascii)? - .trimmingCharacters(in: .controlCharacters) ?? "unknown" - - logger.debug("Device model identified as: \(identifier)") - return identifier - }() -} - -/// SwiftUI view extension for applying custom notch overrides -@available(iOS 13.0, *) -public extension View { - /// Applies custom notch/island overrides to this view hierarchy - /// - Parameter overrides: The device overrides to apply - /// - Returns: A view with the specified notch overrides - func notchOverrides(_ overrides: [DeviceOverride]) -> some View { - modifier(NotchOverrideModifier(overrides: overrides)) - } - func notchOverrides(_ overrides: [DeviceOverride]?) -> some View { - modifier(NotchOverrideModifier(overrides: overrides.map { $0 } ?? [])) - } - - /// Applies a single custom notch/island override to this view hierarchy - /// - Parameter override: The device override to apply - /// - Returns: A view with the specified notch override - func notchOverride(_ override: DeviceOverride) -> some View { - notchOverrides([override]) - } - - /// Conditionally applies a custom notch/island override to this view hierarchy - /// - Parameter override: Optional device override to apply (nil means no override) - /// - Returns: A view with the specified notch override if provided - func notchOverride(_ override: DeviceOverride?) -> some View { - modifier(NotchOverrideModifier(overrides: override.map { [$0] } ?? [])) - } -} - -/// Environment key for notch overrides -@available(iOS 13.0, *) -private struct NotchOverridesKey: EnvironmentKey { - static let defaultValue: [DeviceOverride]? = nil -} - -/// Environment extension for notch overrides -@available(iOS 13.0, *) -public extension EnvironmentValues { - /// Custom notch overrides for the current environment - var notchOverrides: [DeviceOverride]? { - get { self[NotchOverridesKey.self] } - set { self[NotchOverridesKey.self] = newValue } - } -} - -/// View modifier for applying notch overrides -@available(iOS 13.0, *) -private struct NotchOverrideModifier: ViewModifier { - @ObservedObject private var notchManager = NotchMyProblem.shared - let overrides: [DeviceOverride] - - func body(content: Content) -> some View { - content.environment(\.notchOverrides, overrides) - } -} From d4b305a63b8da5031aa5c5d0716f1c0e8e9c4350 Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:55:47 +0100 Subject: [PATCH 02/10] Add configurable padding to CutoutAccessoryView Introduces the CutoutAccessoryPadding enum to allow .auto, .none, or custom padding for the cutout and content areas in CutoutAccessoryView. Updates the view's layout logic to use the selected padding configuration and enhances documentation and previews to demonstrate the new options. --- .../NotchMyProblem/CutoutAccessoryView.swift | 253 +++++++++++++----- 1 file changed, 193 insertions(+), 60 deletions(-) diff --git a/Sources/NotchMyProblem/CutoutAccessoryView.swift b/Sources/NotchMyProblem/CutoutAccessoryView.swift index 12609ba..4adaa4e 100644 --- a/Sources/NotchMyProblem/CutoutAccessoryView.swift +++ b/Sources/NotchMyProblem/CutoutAccessoryView.swift @@ -7,34 +7,82 @@ import SwiftUI +/// Padding configuration for `CutoutAccessoryView`. +/// +/// - `.auto`: Uses default heuristics for cutout, content, and vertical padding. +/// - The cutout area gets `cutoutWidth / 8` horizontal padding. +/// - The overall content gets `cutoutWidth / 4` horizontal padding. +/// - The vertical padding is `cutoutHeight * 0.05`. +/// +/// - `.none`: No extra padding is applied to the cutout, content, or vertically. +/// +/// - `.custom`: Supply closures to calculate the horizontal padding for the cutout area, +/// the overall content, and the vertical padding. Each closure receives the relevant +/// cutout dimension (in points) and should return the desired padding (in points). +/// +/// Example: +/// ```swift +/// CutoutAccessoryView( +/// padding: .custom( +/// cutout: { cutoutWidth in cutoutWidth / 10 }, // Horizontal padding for the cutout area +/// content: { cutoutWidth in cutoutWidth / 2 }, // Horizontal padding for the overall content +/// vertical: { cutoutHeight in cutoutHeight * 0.1 } // Vertical padding (default is 5% of cutout height) +/// ), +/// leadingContent: { ... }, +/// trailingContent: { ... } +/// ) +/// ``` +/// +/// - `cutout`: Closure to determine the horizontal padding for the cutout's surroundings (the space reserved for the notch/island). +/// - `content`: Closure to determine the horizontal padding for the overall content (the HStack containing your views). +/// - `vertical`: Closure to determine the vertical padding for the content (default is `{ $0 * 0.05 }`). +public enum CutoutAccessoryPadding { + case auto + case none + case custom( + cutout: (CGFloat) -> CGFloat, + content: (CGFloat) -> CGFloat, + vertical: (CGFloat) -> CGFloat = { $0 * 0.05 } + ) +} + /// A view that positions content around the physical topology of the device's top area, /// adapting to notches, Dynamic Islands, and other screen cutouts automatically. /// /// You can provide any views for the leading and trailing sides. -/// - leadingContent: The view to display on the left side -/// - trailingContent: The view to display on the right side +/// +/// - Parameters: +/// - padding: The padding configuration for the cutout and content area. Default is `.auto`. +/// - leadingContent: The view to display on the left side. +/// - trailingContent: The view to display on the right side. @available(iOS 13.0, *) public struct CutoutAccessoryView: View { // Environment access to any custom overrides @Environment(\.notchOverrides) private var environmentOverrides - + // The view that appears on the left/leading side let leadingContent: LeadingContent - + // The view that appears on the right/trailing side let trailingContent: TrailingContent - - // Access class + + // Padding configuration + let padding: CutoutAccessoryPadding + + // Access class for device topology let notchMyProblem = NotchMyProblem.self - + /// Creates a new CutoutAccessoryView with custom leading and trailing content. /// - Parameters: + /// - padding: The padding configuration for the cutout and content area. Default is `.auto`. /// - leadingContent: The view to display on the left side. /// - trailingContent: The view to display on the right side. public init( + padding: CutoutAccessoryPadding = .auto, @ViewBuilder leadingContent: () -> LeadingContent, @ViewBuilder trailingContent: () -> TrailingContent ) { + self.padding = padding self.leadingContent = leadingContent() self.trailingContent = trailingContent() } @@ -45,23 +93,41 @@ public struct CutoutAccessoryView: let statusBarHeight = geometry.safeAreaInsets.top let hasTopCutout = statusBarHeight > 40 + let exclusionWidth = getAdjustedExclusionRect()?.width ?? 0 + + let exclusionHeight = notchMyProblem.exclusionRect?.height ?? 0 + + let (cutoutPadding, contentPadding, verticalPadding): (CGFloat, CGFloat, CGFloat) = { + switch padding { + case .auto: + return (exclusionWidth / 8, exclusionWidth / 4, exclusionHeight * 0.05) + case .none: + return (0, 0, 0) + case .custom(let cutout, let content, let vertical): + return (cutout(exclusionWidth), content(exclusionWidth), vertical(exclusionHeight)) + } + }() + HStack(spacing: 0) { + // Leading content, aligned appropriately leadingContent .frame(maxWidth: .infinity, alignment: hasTopCutout ? .center : .leading) // Space for the device's top cutout if present - if hasTopCutout, let exclusionWidth = getAdjustedExclusionRect()?.width, exclusionWidth > 0 { + if hasTopCutout, exclusionWidth > 0 { Color.clear .frame(width: exclusionWidth) + .padding(.horizontal, cutoutPadding) } + // Trailing content, aligned appropriately trailingContent .frame(maxWidth: .infinity, alignment: hasTopCutout ? .center : .trailing) -// .padding(7) } - // Adjust height based on device topology + .padding(.vertical, verticalPadding) .frame(height: hasTopCutout ? notchMyProblem.exclusionRect?.height ?? statusBarHeight : 40) .padding(.top, notchMyProblem.exclusionRect?.minY ?? (hasTopCutout ? 0 : 5)) + .padding(.horizontal, contentPadding) .edgesIgnoringSafeArea(.all) } } @@ -70,77 +136,144 @@ public struct CutoutAccessoryView: private func getAdjustedExclusionRect() -> CGRect? { if let overrides = environmentOverrides { // Use environment-specific overrides if available - let rect = notchMyProblem.shared.adjustedExclusionRect(using: overrides) - return rect + return notchMyProblem.shared.adjustedExclusionRect(using: overrides) } else { // Otherwise use the instance's configured overrides - let rect = notchMyProblem.shared.adjustedExclusionRect - return rect + return notchMyProblem.shared.adjustedExclusionRect } } } -#Preview { - // Default CutoutAccessoryView +// MARK: - Previews + +#Preview("Default (.auto)") { + ZStack { + NavigationView { + ScrollView { + VStack(alignment: .leading) { + Text("Recommended for most cases. Adds horizontal padding to both the cutout area and the overall content, and vertical padding based on the cutout height. Ensures content doesn't touch the notch, device corners, or crowd the top edge.") + .font(.subheadline) + .padding(.horizontal) + } + } + .navigationBarTitle("Default (.auto) Padding", displayMode: .inline) + } CutoutAccessoryView( leadingContent: { - Color.red + Capsule() + .fill(.red) + .overlay(Text("Leading").foregroundColor(.white)) }, trailingContent: { - Color.red + Capsule() + .fill(.red) + .overlay(Text("Trailing").foregroundColor(.white)) } ) - .previewDisplayName("Default") - + } } -#Preview { - - - // CutoutAccessoryView with view-specific override - CutoutAccessoryView( - leadingContent: { - Button(action: { - print("Override: Back tapped") - }) { - Image(systemName: "arrow.left") - .font(.headline) - } - }, - trailingContent: { - Button(action: { - print("Override: Save tapped") - }) { - Text("Save") - .font(.headline) +#Preview("No Padding (.none)") { + ZStack { + NavigationView { + ScrollView { + VStack(alignment: .leading) { + Text("No extra padding is applied. Content may touch the notch, device corners, or top edge. Use for full-bleed designs or when you want to manage spacing yourself.") + .font(.subheadline) + .padding(.horizontal) + } } + .navigationBarTitle("No Padding (.none)", displayMode: .inline) } - ) - .notchOverride(.series(prefix: "iPhone14", scale: 0.6, heightFactor: 0.6)) - .previewDisplayName("With View Override") + CutoutAccessoryView( + padding: .none, + leadingContent: { + Capsule() + .fill(.red) + .overlay(Text("Leading").foregroundColor(.white)) + }, + trailingContent: { + Capsule() + .fill(.red) + .overlay(Text("Trailing").foregroundColor(.white)) + } + ) + } } -#Preview{ - // Another variant with different styling - CutoutAccessoryView( - leadingContent: { - Button(action: { - print("Styled: Cancel tapped") - }) { - Text("Cancel") +#Preview("Custom Padding (.custom)") { + ZStack { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("Custom math for cutout, content, and vertical padding.") + .font(.subheadline) + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("The cutout area receives horizontal padding equal to 1/12 of the cutout’s width.") + Text("The overall content receives horizontal padding equal to 1/6 of the cutout’s width.") + Text("Vertical padding is set to 20% of the cutout’s height.") + } .font(.subheadline) - .foregroundColor(.red) + } + .padding(.horizontal) + } + .navigationBarTitle("Custom (.custom)", displayMode: .inline) + } + CutoutAccessoryView( + padding: .custom( + cutout: { $0 / 12 }, + content: { $0 / 6 }, + vertical: { $0 * 0.2 } + ), + leadingContent: { + Capsule() + .fill(.red) + .overlay(Text("Leading").foregroundColor(.white)) + }, + trailingContent: { + Capsule() + .fill(.red) + .overlay(Text("Trailing").foregroundColor(.white)) } - }, - trailingContent: { - Button(action: { - print("Styled: Confirm tapped") - }) { - Text("Confirm") + ) + } +} + +#Preview("With View Override (iPhone 14)") { + ZStack { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("On some devices, the system-reported cutout information can be inaccurate.") + .font(.subheadline) + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("This preview demonstrates using a view-specific override to ensure correct cutout handling on iPhone 14 models.") + Text("(Overrides are provided by default and can be customized—see documentation.)") + } .font(.subheadline) - .foregroundColor(.green) + } + .padding(.horizontal) } + .navigationBarTitle("Override (iPhone 14)", displayMode: .inline) } - ) - .previewDisplayName("Custom Styled") + CutoutAccessoryView( + leadingContent: { + Capsule() + .fill(.red) + .overlay(Text("Leading").foregroundColor(.white)) + }, + trailingContent: { + Capsule() + .fill(.red) + .overlay(Text("Trailing").foregroundColor(.white)) + } + ) + .notchOverride(.series(prefix: "iPhone14", scale: 0.6, heightFactor: 0.6)) + } } From 6ef795992bf5b9257c861bbceb6312d454252f1e Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:59:17 +0100 Subject: [PATCH 03/10] Update CutoutAccessoryView.swift --- Sources/NotchMyProblem/CutoutAccessoryView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/NotchMyProblem/CutoutAccessoryView.swift b/Sources/NotchMyProblem/CutoutAccessoryView.swift index 4adaa4e..32f9d68 100644 --- a/Sources/NotchMyProblem/CutoutAccessoryView.swift +++ b/Sources/NotchMyProblem/CutoutAccessoryView.swift @@ -159,6 +159,7 @@ public struct CutoutAccessoryView: .navigationBarTitle("Default (.auto) Padding", displayMode: .inline) } CutoutAccessoryView( + padding: .auto, leadingContent: { Capsule() .fill(.red) From 253d0418d251414428b8576a7b89b6f722be9cc0 Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:08:23 +0100 Subject: [PATCH 04/10] Revise README Expanded documentation for CutoutAccessoryView, including detailed explanations and examples for the new `padding` parameter and its customization. Clarified override mechanisms, precedence, and advanced usage. Improved formatting, added rationale for padding, and updated compatibility, logging, and contribution sections for clarity and completeness. --- README.md | 216 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 119 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index e506e19..4e57920 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,19 @@ NotchMyProblem is a lightweight Swift package that makes it easy to position but ## **Installation** -1. Go to File > Add Packages... -2. Enter the repository URL: `https://github.com/Aeastr/NotchMyProblem` -3. Click "Add Package" +1. In Xcode go to **File > Add Packages…** +2. Enter the repository URL: `https://github.com/Aeastr/NotchMyProblem` +3. Click **Add Package** Alternatively, add it to your `Package.swift` dependencies: +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/Aeastr/NotchMyProblem.git", from: "2.0.0") +] +``` + --- ## **Key Components** @@ -36,7 +43,7 @@ NotchMyProblem automatically detects the device type and adjusts the UI accordin ### CutoutAccessoryView -The simplest way to use NotchMyProblem is with the included `CutoutAccessoryView`: +The simplest way to use NotchMyProblem is with the included `CutoutAccessoryView`. Note the new `padding` parameter: ```swift import SwiftUI @@ -46,16 +53,17 @@ struct MyView: View { var body: some View { ZStack { // Your main content here - + // Buttons positioned around the notch/island CutoutAccessoryView( + padding: .auto, // default: horizontal = cutoutWidth/8 & /4, vertical = cutoutHeight*0.05 leadingContent: { - Button(action: { print("Left button tapped") }) { + Button(action: { print("Left tapped") }) { Image(systemName: "gear") } }, trailingContent: { - Button(action: { print("Right button tapped") }) { + Button(action: { print("Right tapped") }) { Text("Save") } } @@ -65,103 +73,128 @@ struct MyView: View { } ``` -This will automatically: -- Position buttons on either side of the notch/Dynamic Island on compatible devices -- Fall back to standard left/right positioning on devices without a notch -- Adjust the spacing based on the specific device model +- **`.auto`**: + - Cutout area → `cutoutWidth / 8` horizontal padding + - Content area → `cutoutWidth / 4` horizontal padding + - Vertical → `cutoutHeight * 0.05` + +- **`.none`**: no extra padding + +- **`.custom(cutout: , content: , vertical:)`**: supply your own closures + +--- + +## **Why Padding?** + +Modern iPhones have notches, Dynamic Islands, and heavily rounded corners. If you place buttons or other UI elements too close to these cutouts you risk: + +- Elements appearing cramped or uncomfortably close to the cutout +- Parts of your UI being clipped by the curved screen edges +- Inconsistent spacing across different device models + +By adding padding that *scales* with the actual cutout dimensions, NotchMyProblem ensures that your content: + +1. Always sits at a safe distance from the notch/island +2. Never collides with the device’s rounded corners +3. Maintains a consistent, polished look on every supported iPhone + +--- + +## **Padding Customization** + +You can control three kinds of padding: + +1. **Cutout padding** – extra space _around_ the notch/island itself +2. **Content padding** – extra space on either side of your HStack content +3. **Vertical padding** – extra space above and below your content + +Use the `padding` parameter when initializing `CutoutAccessoryView`: + +```swift +CutoutAccessoryView( + padding: .auto, // default: cutoutW/8, contentW/4, verticalH*0.05 + leadingContent: { /* … */ }, + trailingContent:{ /* … */ } +) +``` + +### Available Modes + +- **`.auto`** + Applies recommended defaults based on the actual cutout size: + - Cutout padding = `cutoutWidth / 8` + - Content padding = `cutoutWidth / 4` + - Vertical padding = `cutoutHeight × 0.05` + +- **`.none`** + No extra padding; your views will hug the safe-area edges exactly. + +- **`.custom(cutout: , content: , vertical:)`** + Supply closures to compute each padding dynamically: + + ```swift + CutoutAccessoryView( + padding: .custom( + cutout: { cutoutW in cutoutW / 12 }, // 1/12 of cutout width + content: { cutoutW in cutoutW / 6 }, // 1/6 of cutout width + vertical:{ cutoutH in cutoutH * 0.2 } // 20% of cutout height + ), + leadingContent: { /* … */ }, + trailingContent:{ /* … */ } + ) + ``` --- ## **Advanced Usage** -
+
iPhone with notch showing incorrect spacing

Custom Overrides for API Inaccuracies

Some devices report incorrect notch dimensions through the API. Overrides correct the reported values to match actual device dimensions, ensuring consistent UI across all devices.

-NotchMyProblem provides several ways to customize how the notch/island area is handled: - -#### 1. Global Overrides (App-wide) +### 1. Global Overrides (App-wide) ```swift -// In your App's initialization +// In your App’s initialization (e.g. in @main or AppDelegate) NotchMyProblem.globalOverrides = [ .series(prefix: "iPhone13", scale: 0.95, heightFactor: 1.0, radius: 27), DeviceOverride(modelIdentifier: "iPhone14,3", scale: 0.8, heightFactor: 0.7) ] ``` -#### 2. Instance Overrides +### 2. Instance Overrides ```swift -// For specific use cases +// At runtime, for specific cases NotchMyProblem.shared.overrides = [ DeviceOverride(modelIdentifier: "iPhone14,3", scale: 0.8, heightFactor: 0.7) ] ``` -#### 3. View-Specific Overrides (using SwiftUI modifiers) +### 3. View-Specific Overrides (SwiftUI) ```swift -CutoutAccessoryView( - leadingContent: { /* ... */ }, - trailingContent: { /* ... */ } -) -.notchOverride(.series(prefix: "iPhone14", scale: 0.6, heightFactor: 0.6)) +CutoutAccessoryView(/* … */) + .notchOverride(.series(prefix: "iPhone14", scale: 0.6, heightFactor: 0.6)) ``` -### Override Precedence +#### Override Precedence -Overrides are applied in the following order (highest priority first): -1. View-specific overrides (via `.notchOverride()` modifier) -2. Instance-specific exact model matches -3. Instance-specific series matches -4. Global exact model matches -5. Global series matches - ---- - -## **Creating Device Overrides** - -### For Specific Device Models - -```swift -// For a specific device model -let override = DeviceOverride( - modelIdentifier: "iPhone14,3", // Exact model - scale: 0.8, // Width scale (0.8 = 80% of original width) - heightFactor: 0.7, // Height scale (0.7 = 70% of original height) - radius: 24 // Corner radius (for visualization) -) -``` - -### For Device Series - -```swift -// For all devices in a series -let seriesOverride = DeviceOverride.series( - prefix: "iPhone14", // All iPhone 14 models - scale: 0.75, // Width scale - heightFactor: 0.75, // Height scale - radius: 24 // Corner radius -) -``` +1. View-specific overrides +2. Instance-specific exact model +3. Instance-specific series prefix +4. Global exact model +5. Global series prefix --- ## **Manual Access** -If you need direct access to the notch/island dimensions: - ```swift -// Get the raw exclusion rect (unmodified) -let rawRect = NotchMyProblem.exclusionRect - -// Get the adjusted rect with any applicable overrides -let adjustedRect = NotchMyProblem.shared.adjustedExclusionRect - -// Get a custom-adjusted rect with specific overrides +let rawRect = NotchMyProblem.exclusionRect // raw API result +let adjusted = NotchMyProblem.shared.adjustedExclusionRect // with global/instance overrides let customRect = NotchMyProblem.shared.adjustedExclusionRect(using: myOverrides) ``` @@ -169,57 +202,46 @@ let customRect = NotchMyProblem.shared.adjustedExclusionRect(using: myOverrides) ## **How It Works** -NotchMyProblem uses a safe approach to access the device's notch/Dynamic Island information: - -1. It retrieves the exclusion area using Objective-C runtime features -2. It safely checks for the existence of methods before calling them -3. It applies device-specific adjustments based on the model identifier -4. It provides fallbacks if the information cannot be retrieved - -The package is designed to be robust against API changes and includes comprehensive logging to help diagnose any issues. +1. Uses Objective-C runtime to safely fetch the exclusion area +2. Falls back gracefully if the API is unavailable +3. Applies device-specific scale/height overrides +4. Provides SwiftUI modifiers and environment overrides for fine-grained control +5. Includes logging (iOS 14+ `Logger`, iOS 13 `os_log`) --- ## **Compatibility** -- Requires iOS 13.0 or later -- Supports all notched iPhones (X, XS, XR, 11, 12, 13 series) -- Supports Dynamic Island devices (iPhone 14 Pro and newer) -- Safely falls back on devices without notches +- iOS 13.0+ +- All notched iPhones (X → 14, 16e…) +- Dynamic Island devices (14 Pro, newer) +- Fallback for devices without cutouts --- ## **Logging** -NotchMyProblem includes built-in logging that works across iOS versions: -- Uses `Logger` on iOS 14+ -- Falls back to `os_log` on iOS 13 -- Provides helpful debug information - -To see logs, filter Console app output with subsystem: `com.notchmyproblem` +Filter Console with subsystem `com.notchmyproblem` to see debug/info/error logs. --- -## License +## **License** -This project is released under the MIT License. See [LICENSE](LICENSE.md) for details. +MIT — see [LICENSE.md](LICENSE.md) +## **Contributing** -## Contributing +Please review [CONTRIBUTING.md](CONTRIBUTING.md) before opening PRs. -Contributions are welcome! Please feel free to submit a Pull Request. Before you begin, take a moment to review the [Contributing Guide](CONTRIBUTING.md) for details on issue reporting, coding standards, and the PR process. +## **Support** -## Support - -If you like this project, please consider giving it a ⭐️ +If you like it, please give a ⭐️ --- # Acknowledgments -- This package uses private API information in a safe, non-invasive way, however use at your own risk considering app store rules -- Check out [TopNotch](https://github.com/samhenrigold/TopNotch) which helped inspire this solution and provided valuable insights into working with the notch/Dynamic Island - ---- +- Inspired by [TopNotch](https://github.com/samhenrigold/TopNotch) +- Uses private APIs safely—use at your own risk

Built with 🍏📱🏝️ by Aether

From 1193512e4d6a9922b22dabbf037f2db6c5b5cd27 Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:11:56 +0100 Subject: [PATCH 05/10] Update README usage and contact info Removed detailed explanation of the CutoutAccessoryView padding parameter for brevity and added new contact and social links at the end of the README. --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4e57920..f2b80dd 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ NotchMyProblem automatically detects the device type and adjusts the UI accordin ### CutoutAccessoryView -The simplest way to use NotchMyProblem is with the included `CutoutAccessoryView`. Note the new `padding` parameter: +The simplest way to use NotchMyProblem is with the included `CutoutAccessoryView`. ```swift import SwiftUI @@ -56,7 +56,7 @@ struct MyView: View { // Buttons positioned around the notch/island CutoutAccessoryView( - padding: .auto, // default: horizontal = cutoutWidth/8 & /4, vertical = cutoutHeight*0.05 + padding: .auto, leadingContent: { Button(action: { print("Left tapped") }) { Image(systemName: "gear") @@ -73,15 +73,6 @@ struct MyView: View { } ``` -- **`.auto`**: - - Cutout area → `cutoutWidth / 8` horizontal padding - - Content area → `cutoutWidth / 4` horizontal padding - - Vertical → `cutoutHeight * 0.05` - -- **`.none`**: no extra padding - -- **`.custom(cutout: , content: , vertical:)`**: supply your own closures - --- ## **Why Padding?** @@ -244,4 +235,13 @@ If you like it, please give a ⭐️ - Inspired by [TopNotch](https://github.com/samhenrigold/TopNotch) - Uses private APIs safely—use at your own risk +--- + +## Where to find me: +- here, obviously. +- [Twitter](https://x.com/AetherAurelia) +- [Threads](https://www.threads.net/@aetheraurelia) +- [Bluesky](https://bsky.app/profile/aethers.world) +- [LinkedIn](https://www.linkedin.com/in/willjones24) +

Built with 🍏📱🏝️ by Aether

From 27a3b39873419c3c3d58ac45659c5051c360c520 Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:13:22 +0100 Subject: [PATCH 06/10] Add badges for Swift, iOS, and license to README Introduced shields.io badges to display Swift version, iOS compatibility, and license information in the README for improved project visibility. --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f2b80dd..8af3419 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,18 @@ Logo

NotchMyProblem

Swift package that handles the annoying task of positioning UI elements around the iPhone's notch and Dynamic Island
- Compatible with iOS 13.0 and later

+
+ + ## **Overview** From 25dd14dff424ca9ac26c2014631ab59d37e25f1d Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:23:45 +0100 Subject: [PATCH 07/10] Adjust cutout and content padding logic in accessory view Updated the default exclusionWidth fallback to use 30% of the view width if unavailable. Modified padding logic to apply content padding only when a top cutout is present, and simplified the condition for rendering the cutout space. --- Sources/NotchMyProblem/CutoutAccessoryView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/NotchMyProblem/CutoutAccessoryView.swift b/Sources/NotchMyProblem/CutoutAccessoryView.swift index 32f9d68..1483e07 100644 --- a/Sources/NotchMyProblem/CutoutAccessoryView.swift +++ b/Sources/NotchMyProblem/CutoutAccessoryView.swift @@ -93,14 +93,14 @@ public struct CutoutAccessoryView: let statusBarHeight = geometry.safeAreaInsets.top let hasTopCutout = statusBarHeight > 40 - let exclusionWidth = getAdjustedExclusionRect()?.width ?? 0 + let exclusionWidth = getAdjustedExclusionRect()?.width ?? geometry.size.width * 0.3 let exclusionHeight = notchMyProblem.exclusionRect?.height ?? 0 let (cutoutPadding, contentPadding, verticalPadding): (CGFloat, CGFloat, CGFloat) = { switch padding { case .auto: - return (exclusionWidth / 8, exclusionWidth / 4, exclusionHeight * 0.05) + return (exclusionWidth / 8, exclusionWidth / (4), exclusionHeight * 0.05) case .none: return (0, 0, 0) case .custom(let cutout, let content, let vertical): @@ -114,7 +114,7 @@ public struct CutoutAccessoryView: .frame(maxWidth: .infinity, alignment: hasTopCutout ? .center : .leading) // Space for the device's top cutout if present - if hasTopCutout, exclusionWidth > 0 { + if exclusionWidth > 0 { Color.clear .frame(width: exclusionWidth) .padding(.horizontal, cutoutPadding) @@ -127,7 +127,7 @@ public struct CutoutAccessoryView: .padding(.vertical, verticalPadding) .frame(height: hasTopCutout ? notchMyProblem.exclusionRect?.height ?? statusBarHeight : 40) .padding(.top, notchMyProblem.exclusionRect?.minY ?? (hasTopCutout ? 0 : 5)) - .padding(.horizontal, contentPadding) + .padding(.horizontal, hasTopCutout ? contentPadding : 5) .edgesIgnoringSafeArea(.all) } } From bf8652a8a441725c51dc971515e27af061c72eac Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:32:04 +0100 Subject: [PATCH 08/10] Adjust default padding and text style in CutoutAccessoryView Updated the `.auto` mode to use new default padding values for cutout, content, and vertical padding. Also set the font of leading and trailing content overlays to `.footnote` for improved appearance. Updated README to reflect the simplified description of `.auto` mode. --- README.md | 5 +---- Sources/NotchMyProblem/CutoutAccessoryView.swift | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8af3419..34c4bf4 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,7 @@ CutoutAccessoryView( ### Available Modes - **`.auto`** - Applies recommended defaults based on the actual cutout size: - - Cutout padding = `cutoutWidth / 8` - - Content padding = `cutoutWidth / 4` - - Vertical padding = `cutoutHeight × 0.05` + Applies recommended defaults based on the actual cutout size - **`.none`** No extra padding; your views will hug the safe-area edges exactly. diff --git a/Sources/NotchMyProblem/CutoutAccessoryView.swift b/Sources/NotchMyProblem/CutoutAccessoryView.swift index 1483e07..a012d19 100644 --- a/Sources/NotchMyProblem/CutoutAccessoryView.swift +++ b/Sources/NotchMyProblem/CutoutAccessoryView.swift @@ -100,7 +100,7 @@ public struct CutoutAccessoryView: let (cutoutPadding, contentPadding, verticalPadding): (CGFloat, CGFloat, CGFloat) = { switch padding { case .auto: - return (exclusionWidth / 8, exclusionWidth / (4), exclusionHeight * 0.05) + return (exclusionWidth / 6, exclusionWidth / (3), exclusionHeight * 0.1) case .none: return (0, 0, 0) case .custom(let cutout, let content, let vertical): @@ -163,12 +163,12 @@ public struct CutoutAccessoryView: leadingContent: { Capsule() .fill(.red) - .overlay(Text("Leading").foregroundColor(.white)) + .overlay(Text("Leading").font(.footnote).foregroundColor(.white)) }, trailingContent: { Capsule() .fill(.red) - .overlay(Text("Trailing").foregroundColor(.white)) + .overlay(Text("Trailing").font(.footnote).foregroundColor(.white)) } ) } From 56b602e7ced303c37490377562515c4719789fa4 Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:34:54 +0100 Subject: [PATCH 09/10] Adjust default exclusion width and frame height Increased the default exclusion width fallback from 30% to 40% of the view width and reduced the fallback frame height from 40 to 30 when there is no top cutout. These changes refine the layout for better visual alignment. --- Sources/NotchMyProblem/CutoutAccessoryView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/NotchMyProblem/CutoutAccessoryView.swift b/Sources/NotchMyProblem/CutoutAccessoryView.swift index a012d19..b27a281 100644 --- a/Sources/NotchMyProblem/CutoutAccessoryView.swift +++ b/Sources/NotchMyProblem/CutoutAccessoryView.swift @@ -93,7 +93,7 @@ public struct CutoutAccessoryView: let statusBarHeight = geometry.safeAreaInsets.top let hasTopCutout = statusBarHeight > 40 - let exclusionWidth = getAdjustedExclusionRect()?.width ?? geometry.size.width * 0.3 + let exclusionWidth = getAdjustedExclusionRect()?.width ?? geometry.size.width * 0.4 let exclusionHeight = notchMyProblem.exclusionRect?.height ?? 0 @@ -125,7 +125,7 @@ public struct CutoutAccessoryView: .frame(maxWidth: .infinity, alignment: hasTopCutout ? .center : .trailing) } .padding(.vertical, verticalPadding) - .frame(height: hasTopCutout ? notchMyProblem.exclusionRect?.height ?? statusBarHeight : 40) + .frame(height: hasTopCutout ? notchMyProblem.exclusionRect?.height ?? statusBarHeight : 30) .padding(.top, notchMyProblem.exclusionRect?.minY ?? (hasTopCutout ? 0 : 5)) .padding(.horizontal, hasTopCutout ? contentPadding : 5) .edgesIgnoringSafeArea(.all) From 2c93fe0170c76649066d28691703776a03ebcbca Mon Sep 17 00:00:00 2001 From: Aether <64797587+Aeastr@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:36:19 +0100 Subject: [PATCH 10/10] Add GitHub Actions workflow for Swift iOS tests Introduces a workflow to build and test the Swift package on iOS using Swift 6.0 and Xcode on macOS runners. The workflow runs on push and pull requests to main, supports both debug and release configurations, and dynamically selects an available iOS simulator. --- .github/workflows/swift.yml | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/swift.yml diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..7420032 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,56 @@ +name: Test Swift Package on iOS + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Test Swift 6.0 on iOS (${{ matrix.config }}) + runs-on: macos-latest # iOS testing requires macOS + + strategy: + matrix: + config: [debug, release] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select Xcode (latest) + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 'latest' + + - name: Setup Swift 6.0 + uses: SwiftyLab/setup-swift@latest + with: + swift-version: '6.0' # Use quotes to ensure correct version parsing + + - name: Check Swift version + run: swift --version + + - name: Find an iOS Simulator + id: find_simulator + run: | + # Find an available iOS simulator runtime and device + RUNTIME_ID=$(xcrun simctl list runtimes ios --json | jq -r '.runtimes[0].identifier') + if [ -z "$RUNTIME_ID" ] || [ "$RUNTIME_ID" == "null" ]; then + echo "::error::No iOS runtime found." + exit 1 + fi + DEVICE_ID=$(xcrun simctl list devices --json | jq -r --arg RT_ID "$RUNTIME_ID" '.devices[$RT_ID] | map(select(.isAvailable)) | .[0].udid') + if [ -z "$DEVICE_ID" ] || [ "$DEVICE_ID" == "null" ]; then + echo "::error::No available iOS simulator device found for runtime $RUNTIME_ID." + exit 1 + fi + echo "Found iOS Simulator Runtime: $RUNTIME_ID" + echo "Found iOS Simulator Device UDID: $DEVICE_ID" + echo "SIMULATOR_DESTINATION=platform=iOS Simulator,id=$DEVICE_ID" >> $GITHUB_OUTPUT + + - name: Build and Test (${{ matrix.config }}) + run: | + echo "Using simulator destination: ${{ steps.find_simulator.outputs.SIMULATOR_DESTINATION }}" + xcodebuild build -scheme NotchMyProblem -sdk $(xcrun --sdk iphonesimulator --show-sdk-path) -destination "${{ steps.find_simulator.outputs.SIMULATOR_DESTINATION }}" SWIFT_VERSION=6.0