diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 6199ee3..f63f732 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -53,4 +53,4 @@ jobs: - name: Build and Test (${{ matrix.config }}) run: | echo "Using simulator destination: ${{ steps.find_simulator.outputs.SIMULATOR_DESTINATION }}" - xcodebuild build -scheme MenuWithAView -sdk $(xcrun --sdk iphonesimulator --show-sdk-path) -destination "${{ steps.find_simulator.outputs.SIMULATOR_DESTINATION }}" SWIFT_VERSION=6.0 + xcodebuild build -scheme MenuWithAView -sdk iphonesimulator -destination "${{ steps.find_simulator.outputs.SIMULATOR_DESTINATION }}" SWIFT_VERSION=6.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a46a6e..1823faa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Please read and follow the [Code of Conduct](./CODE_OF_CONDUCT.md). We're commit Before opening a new issue, search existing issues to avoid duplicates. When filing a bug report, please include: - MenuWithAView version (e.g. `0.1.2`) and your Swift/Xcode versions -- Target platform (iOS 15.0+), device or simulator +- Target platform (iOS 16.0+), device or simulator - A concise description of the problem and steps to reproduce - Minimal code snippet or sample project demonstrating the issue - Any relevant console logs or screenshots diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/Info.plist b/Frameworks/ContextMenuAccessoryStructs.xcframework/Info.plist deleted file mode 100644 index c3e1b61..0000000 --- a/Frameworks/ContextMenuAccessoryStructs.xcframework/Info.plist +++ /dev/null @@ -1,44 +0,0 @@ - - - - - AvailableLibraries - - - BinaryPath - ContextMenuAccessoryStructs.framework/ContextMenuAccessoryStructs - LibraryIdentifier - ios-arm64_x86_64-simulator - LibraryPath - ContextMenuAccessoryStructs.framework - SupportedArchitectures - - arm64 - x86_64 - - SupportedPlatform - ios - SupportedPlatformVariant - simulator - - - BinaryPath - ContextMenuAccessoryStructs.framework/ContextMenuAccessoryStructs - LibraryIdentifier - ios-arm64 - LibraryPath - ContextMenuAccessoryStructs.framework - SupportedArchitectures - - arm64 - - SupportedPlatform - ios - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/ContextMenuAccessoryStructs b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/ContextMenuAccessoryStructs deleted file mode 100755 index c93f407..0000000 Binary files a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/ContextMenuAccessoryStructs and /dev/null differ diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/Headers/ContextMenuAccessoryStructs.h b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/Headers/ContextMenuAccessoryStructs.h deleted file mode 100644 index d27d8bb..0000000 --- a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/Headers/ContextMenuAccessoryStructs.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContextMenuAccessoryStructs.h -// ContextMenuAccessoryStructs -// -// Created by Seb Vidal on 11/05/2025. -// - -#import - -//! Project version number for ContextMenuAccessoryStructs. -FOUNDATION_EXPORT double ContextMenuAccessoryStructsVersionNumber; - -//! Project version string for ContextMenuAccessoryStructs. -FOUNDATION_EXPORT const unsigned char ContextMenuAccessoryStructsVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - -typedef struct { - unsigned long long attachment; - unsigned long long alignment; - double attachmentOffset; - double alignmentOffset; - long long gravity; -} ContextMenuAccessoryAnchor; diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/Info.plist b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/Info.plist deleted file mode 100644 index 15fba5d..0000000 Binary files a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/Info.plist and /dev/null differ diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/Modules/module.modulemap b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/Modules/module.modulemap deleted file mode 100644 index ba0f298..0000000 --- a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/Modules/module.modulemap +++ /dev/null @@ -1,6 +0,0 @@ -framework module ContextMenuAccessoryStructs { - umbrella header "ContextMenuAccessoryStructs.h" - export * - - module * { export * } -} diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/_CodeSignature/CodeResources b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/_CodeSignature/CodeResources deleted file mode 100644 index 2ef3b14..0000000 --- a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64/ContextMenuAccessoryStructs.framework/_CodeSignature/CodeResources +++ /dev/null @@ -1,124 +0,0 @@ - - - - - files - - Headers/ContextMenuAccessoryStructs.h - - b9Luviw1b76Lr6p1xgNrWNfPCVY= - - Info.plist - - oS7eZhHij+Sz+QXDnS0j7qeyoFY= - - Modules/module.modulemap - - ATR3fNklhb4n/v9ZT1/V52kwESk= - - - files2 - - Headers/ContextMenuAccessoryStructs.h - - hash2 - - SwK0arudKlqsKFRArld22fmqwIqpiFxdeNrWJ36DmvQ= - - - Modules/module.modulemap - - hash2 - - bNjsWBrAeGn0pRiBgSwZN5WWpE7sPRHaQT1gVEZp5Eg= - - - - rules - - ^.* - - ^.*\.lproj/ - - optional - - weight - 1000 - - ^.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Base\.lproj/ - - weight - 1010 - - ^version.plist$ - - - rules2 - - .*\.dSYM($|/) - - weight - 11 - - ^(.*/)?\.DS_Store$ - - omit - - weight - 2000 - - ^.* - - ^.*\.lproj/ - - optional - - weight - 1000 - - ^.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Base\.lproj/ - - weight - 1010 - - ^Info\.plist$ - - omit - - weight - 20 - - ^PkgInfo$ - - omit - - weight - 20 - - ^embedded\.provisionprofile$ - - weight - 20 - - ^version\.plist$ - - weight - 20 - - - - diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/ContextMenuAccessoryStructs b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/ContextMenuAccessoryStructs deleted file mode 100755 index 0a0b35e..0000000 Binary files a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/ContextMenuAccessoryStructs and /dev/null differ diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/Headers/ContextMenuAccessoryStructs.h b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/Headers/ContextMenuAccessoryStructs.h deleted file mode 100644 index d27d8bb..0000000 --- a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/Headers/ContextMenuAccessoryStructs.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContextMenuAccessoryStructs.h -// ContextMenuAccessoryStructs -// -// Created by Seb Vidal on 11/05/2025. -// - -#import - -//! Project version number for ContextMenuAccessoryStructs. -FOUNDATION_EXPORT double ContextMenuAccessoryStructsVersionNumber; - -//! Project version string for ContextMenuAccessoryStructs. -FOUNDATION_EXPORT const unsigned char ContextMenuAccessoryStructsVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - -typedef struct { - unsigned long long attachment; - unsigned long long alignment; - double attachmentOffset; - double alignmentOffset; - long long gravity; -} ContextMenuAccessoryAnchor; diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/Info.plist b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/Info.plist deleted file mode 100644 index 09eacdb..0000000 Binary files a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/Info.plist and /dev/null differ diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/Modules/module.modulemap b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/Modules/module.modulemap deleted file mode 100644 index ba0f298..0000000 --- a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/Modules/module.modulemap +++ /dev/null @@ -1,6 +0,0 @@ -framework module ContextMenuAccessoryStructs { - umbrella header "ContextMenuAccessoryStructs.h" - export * - - module * { export * } -} diff --git a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/_CodeSignature/CodeResources b/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/_CodeSignature/CodeResources deleted file mode 100644 index 1ee20d8..0000000 --- a/Frameworks/ContextMenuAccessoryStructs.xcframework/ios-arm64_x86_64-simulator/ContextMenuAccessoryStructs.framework/_CodeSignature/CodeResources +++ /dev/null @@ -1,124 +0,0 @@ - - - - - files - - Headers/ContextMenuAccessoryStructs.h - - b9Luviw1b76Lr6p1xgNrWNfPCVY= - - Info.plist - - 5DvCNs2Br0B+tFd3LGX2GWX3YI4= - - Modules/module.modulemap - - ATR3fNklhb4n/v9ZT1/V52kwESk= - - - files2 - - Headers/ContextMenuAccessoryStructs.h - - hash2 - - SwK0arudKlqsKFRArld22fmqwIqpiFxdeNrWJ36DmvQ= - - - Modules/module.modulemap - - hash2 - - bNjsWBrAeGn0pRiBgSwZN5WWpE7sPRHaQT1gVEZp5Eg= - - - - rules - - ^.* - - ^.*\.lproj/ - - optional - - weight - 1000 - - ^.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Base\.lproj/ - - weight - 1010 - - ^version.plist$ - - - rules2 - - .*\.dSYM($|/) - - weight - 11 - - ^(.*/)?\.DS_Store$ - - omit - - weight - 2000 - - ^.* - - ^.*\.lproj/ - - optional - - weight - 1000 - - ^.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Base\.lproj/ - - weight - 1010 - - ^Info\.plist$ - - omit - - weight - 20 - - ^PkgInfo$ - - omit - - weight - 20 - - ^embedded\.provisionprofile$ - - weight - 20 - - ^version\.plist$ - - weight - 20 - - - - diff --git a/Package.swift b/Package.swift index 053faf2..0772021 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "MenuWithAView", platforms: [ - .iOS(.v18) + .iOS(.v16) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. @@ -14,6 +14,10 @@ let package = Package( name: "MenuWithAView", targets: ["MenuWithAView"] ), + .library( + name: "ContextMenuAccessoryStructs", + targets: ["ContextMenuAccessoryStructs"] + ) ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -22,9 +26,8 @@ let package = Package( name: "MenuWithAView", dependencies: ["ContextMenuAccessoryStructs"] ), - .binaryTarget( - name: "ContextMenuAccessoryStructs", - path: "Frameworks/ContextMenuAccessoryStructs.xcframework" + .target( + name: "ContextMenuAccessoryStructs" ) ] ) diff --git a/README.md b/README.md index 92fc327..bb30de0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

MenuWithAView is a SwiftUI package that lets you add accessory views to your context menu interactions, with UIKit's private _UIContextMenuAccessoryView.
- Compatible with iOS 18 and later + Compatible with iOS 16 and later

@@ -14,7 +14,7 @@ Swift Version - iOS + iOS License: MIT @@ -29,23 +29,27 @@ ## contextMenuAccessory -`contextMenuAccessory` is a SwiftUI modifier that lets you attach an accessory view to a `.contextMenu`. You can control the accessory’s placement, location, alignment, and tracking axis. +`contextMenuAccessory` is a SwiftUI modifier that lets you attach an accessory view to a `.contextMenu`. You can control the accessory's placement, location, alignment, and tracking axis. There are two variants: one for simple accessory views and another that provides a `ContextMenuProxy` for programmatic dismissal. **DocC documentation is available for this modifier.** ### Parameters -- `placement`: Where the accessory is attached relative to the context menu. +- `placement`: Where the accessory is attached relative to the context menu. *(Default: `.center`)* -- `location`: The location where the accessory appears. +- `location`: The location where the accessory appears. *(Default: `.preview`)* -- `alignment`: How the accessory aligns within its container. +- `alignment`: How the accessory aligns within its container. *(Default: `.leading`)* -- `trackingAxis`: The axis along which the accessory tracks user interaction. +- `trackingAxis`: The axis along which the accessory tracks user interaction. *(Default: `[.xAxis, .yAxis]`)* -- `accessory`: The view to display as the accessory. +- `accessory`: A view builder that returns the accessory view. Available in two variants: + - Simple: `@ViewBuilder accessory: () -> AccessoryView` + - With proxy: `@ViewBuilder accessory: (ContextMenuProxy) -> AccessoryView` -### Example +### Examples + +#### Basic Usage ```swift Text("Turtle Rock") @@ -70,6 +74,32 @@ Text("Turtle Rock") } ``` +#### With Programmatic Dismissal + +```swift +Text("Turtle Rock") + .padding() + .contextMenu { + Button(action: {}) { + Label("Button", systemImage: "circle") + } + } + .contextMenuAccessory(placement: .center) { proxy in + VStack { + Text("Accessory View") + .font(.title2) + + Button("Dismiss") { + proxy.dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding() + .background(Color.blue.opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +``` + --- ## **Acknowledgments** diff --git a/Sources/ContextMenuAccessoryStructs/ContextMenuAccessoryStructs.m b/Sources/ContextMenuAccessoryStructs/ContextMenuAccessoryStructs.m new file mode 100644 index 0000000..eb05a0c --- /dev/null +++ b/Sources/ContextMenuAccessoryStructs/ContextMenuAccessoryStructs.m @@ -0,0 +1,8 @@ +// +// ContextMenuAccessoryStructs.m +// MenuWithAView +// +// Created by Nathan Tannar on 2025-09-07. +// + +#import "ContextMenuAccessoryStructs.h" diff --git a/Sources/ContextMenuAccessoryStructs/include/ContextMenuAccessoryStructs.h b/Sources/ContextMenuAccessoryStructs/include/ContextMenuAccessoryStructs.h new file mode 100644 index 0000000..c2fd16a --- /dev/null +++ b/Sources/ContextMenuAccessoryStructs/include/ContextMenuAccessoryStructs.h @@ -0,0 +1,7 @@ +typedef struct { + unsigned long long attachment; + unsigned long long alignment; + double attachmentOffset; + double alignmentOffset; + long long gravity; +} ContextMenuAccessoryAnchor; diff --git a/Sources/MenuWithAView/AccessoryItem.swift b/Sources/MenuWithAView/AccessoryItem.swift index fde2ef7..9ee3257 100644 --- a/Sources/MenuWithAView/AccessoryItem.swift +++ b/Sources/MenuWithAView/AccessoryItem.swift @@ -8,37 +8,6 @@ import SwiftUI import ContextMenuAccessoryStructs -struct AccessoryItem: View { - let configuration: Configuration - let content: () -> Content - - init(configuration: ContextMenuAccessoryConfiguration, content: @escaping () -> Content) { - self.configuration = configuration - self.content = content - } - - init(placement: Placement, content: @escaping () -> Content) { - self.configuration = Configuration(placement: placement) - self.content = content - } - - var body: some View { - content() - } -} - -extension AccessoryItem { - public typealias Location = ContextMenuAccessoryLocation - - public typealias Placement = ContextMenuAccessoryPlacement - - public typealias Alignment = ContextMenuAccessoryAlignment - - public typealias TrackingAxis = ContextMenuAccessoryTrackingAxis - - typealias Configuration = ContextMenuAccessoryConfiguration -} - public enum ContextMenuAccessoryLocation: Int { case background = 0 case preview = 1 @@ -61,7 +30,7 @@ public enum ContextMenuAccessoryAlignment: UInt64 { case trailing = 8 } -public struct ContextMenuAccessoryTrackingAxis: OptionSet, Sendable { +public struct ContextMenuAccessoryTrackingAxis: OptionSet, Sendable, Hashable { public let rawValue: Int public init(rawValue: Int) { @@ -77,10 +46,18 @@ public struct ContextMenuAccessoryTrackingAxis: OptionSet, Sendable { } } +public struct ContextMenuProxy: @unchecked Sendable { + + var dismissBlock: (() -> Void)? + + @MainActor + public func dismiss() { + dismissBlock?() + } +} + /// Configuration for context menu accessories, including placement, location, alignment, and tracking axis. -struct ContextMenuAccessoryConfiguration: Identifiable { - let id: UUID = UUID() - +struct ContextMenuAccessoryConfiguration { var location: ContextMenuAccessoryLocation = .preview // controls the attachment point diff --git a/Sources/MenuWithAView/ContextMenuIdentifierView.swift b/Sources/MenuWithAView/ContextMenuIdentifierView.swift index 10b8abd..1872761 100644 --- a/Sources/MenuWithAView/ContextMenuIdentifierView.swift +++ b/Sources/MenuWithAView/ContextMenuIdentifierView.swift @@ -9,36 +9,90 @@ import UIKit import SwiftUI import ContextMenuAccessoryStructs -struct ContextMenuIdentifierView: UIViewRepresentable { - let accessoryView: () -> AccessoryItem - - func makeUIView(context: Context) -> some UIView { - let rootView = accessoryView() - let hostingView = _UIHostingView(rootView: rootView) - let identifierView = ContextMenuIdentifierUIView(accessoryView: hostingView, configuration: rootView.configuration) - - return identifierView +struct ContextMenuIdentifierView: View { + let configuration: ContextMenuAccessoryConfiguration + let accessory: (ContextMenuProxy) -> AccessoryView + + var body: some View { + ContextMenuIdentifierViewBody( + configuration: configuration, + accessory: accessory + ) + // Disable accessibility so any accessibility modifiers used are + // not applied to `ContextMenuIdentifierViewBody` + .environment(\.accessibilityEnabled, false) } - - func updateUIView(_ uiView: UIViewType, context: Context) {} } -class ContextMenuIdentifierUIView: UIView { - let accessoryView: UIView + +struct ContextMenuIdentifierViewBody: UIViewRepresentable { let configuration: ContextMenuAccessoryConfiguration + let accessory: (ContextMenuProxy) -> AccessoryView + + func makeUIView( + context: Context + ) -> ContextMenuIdentifierUIView { + let uiView = ContextMenuIdentifierUIView( + configuration: configuration, + accessory: accessory + ) + return uiView + } - init(accessoryView: UIView, configuration: ContextMenuAccessoryConfiguration) { - self.accessoryView = accessoryView + func updateUIView( + _ uiView: ContextMenuIdentifierUIView, + context: Context + ) { + uiView.update(accessory) + } +} + +class AnyContextMenuIdentifierUIView: UIView { + var accessoryView: UIView? + var configuration: ContextMenuAccessoryConfiguration + weak var interaction: UIContextMenuInteraction? + + init(configuration: ContextMenuAccessoryConfiguration) { self.configuration = configuration - super.init(frame: .zero) - + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class ContextMenuIdentifierUIView: AnyContextMenuIdentifierUIView { + private var hostingView: _UIHostingView! + + init( + configuration: ContextMenuAccessoryConfiguration, + accessory: (ContextMenuProxy) -> AccessoryView + ) { + super.init(configuration: configuration) + self.hostingView = _UIHostingView(rootView: accessory(makeProxy())) + accessoryView = hostingView + UIContextMenuInteraction.swizzle_delegate_getAccessoryViewsForConfigurationIfNeeded() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func update(_ accessory: (ContextMenuProxy) -> AccessoryView) { + hostingView.rootView = accessory(makeProxy()) + } + + private func makeProxy() -> ContextMenuProxy { + ContextMenuProxy { [weak self] in + self?.dismiss() + } + } + + private func dismiss() { + interaction?.dismissMenu() + } } #Preview { diff --git a/Sources/MenuWithAView/Example.swift b/Sources/MenuWithAView/Example.swift index 8b738c0..e29504a 100644 --- a/Sources/MenuWithAView/Example.swift +++ b/Sources/MenuWithAView/Example.swift @@ -80,7 +80,7 @@ public struct MenuWithAView_Example: View { placement: placement, location: location, alignment: alignment, - trackingAxis: .yAxis + trackingAxis: .xAxis ) { Text("Accessory View") .font(.title2) @@ -170,6 +170,203 @@ public struct MenuWithAView_Example: View { } } -#Preview { +// MARK: - Test Views for ContextMenuProxy and Advanced Features + +public struct ContextMenuProxyTestView: View { + @State private var dismissCount = 0 + @State private var lastDismissTime = Date() + + public init() {} + + public var body: some View { + NavigationStack { + VStack(spacing: 30) { + Text("Test programmatic dismissal with ContextMenuProxy") + .font(.headline) + .multilineTextAlignment(.center) + .padding() + + VStack(spacing: 20) { + // Basic dismissal test + RoundedRectangle(cornerRadius: 12) + .fill(Color.blue.gradient) + .frame(width: 150, height: 100) + .overlay { + VStack { + Text("Dismissal Test") + .font(.caption) + .foregroundColor(.white) + Text("Long press") + .font(.caption2) + .foregroundColor(.white.opacity(0.8)) + } + } + .contextMenu { + Button("Menu Action") { } + } + .contextMenuAccessory(placement: .center) { proxy in + VStack(spacing: 8) { + Text("Dismissals: \(dismissCount)") + .font(.caption) + + Button("Dismiss") { + dismissCount += 1 + lastDismissTime = Date() + proxy.dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding() + .background(Color.orange.opacity(0.9)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Dismissal with action test + RoundedRectangle(cornerRadius: 12) + .fill(Color.green.gradient) + .frame(width: 150, height: 100) + .overlay { + VStack { + Text("Action + Dismiss") + .font(.caption) + .foregroundColor(.white) + Text("Long press") + .font(.caption2) + .foregroundColor(.white.opacity(0.8)) + } + } + .contextMenu { + Button("Menu Action") { } + } + .contextMenuAccessory(placement: .bottom) { proxy in + VStack(spacing: 4) { + Button("Save & Close") { + // Simulate an action + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + proxy.dismiss() + } + } + .buttonStyle(.bordered) + .font(.caption) + + Button("Cancel") { + proxy.dismiss() + } + .buttonStyle(.borderless) + .font(.caption2) + } + .padding(8) + .background(Color.white.opacity(0.95)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + + Text("Last dismiss: \(lastDismissTime.formatted(date: .omitted, time: .standard))") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + } + .padding() + .navigationTitle("Proxy Tests") + .navigationBarTitleDisplayMode(.inline) + } + } +} + +public struct ViewUpdatesTestView: View { + @State private var counter = 0 + @State private var color = Color.red + @State private var isAnimating = false + + public init() {} + + public var body: some View { + NavigationStack { + VStack(spacing: 30) { + Text("Test real-time view updates in accessory") + .font(.headline) + .multilineTextAlignment(.center) + .padding() + + VStack(spacing: 20) { + Button("Update Counter: \(counter)") { + counter += 1 + color = [Color.red, Color.blue, Color.green, Color.orange, Color.purple].randomElement()! + } + .buttonStyle(.borderedProminent) + + RoundedRectangle(cornerRadius: 12) + .fill(color.gradient) + .frame(width: 200, height: 120) + .scaleEffect(isAnimating ? 1.05 : 1.0) + .animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: isAnimating) + .overlay { + VStack { + Text("Live Updates") + .font(.caption) + .foregroundColor(.white) + Text("Long press") + .font(.caption2) + .foregroundColor(.white.opacity(0.8)) + } + } + .contextMenu { + Button("Action") { counter += 1 } + } + .contextMenuAccessory(placement: .top) { proxy in + VStack(spacing: 8) { + Text("Counter: \(counter)") + .font(.title2) + .foregroundColor(.primary) + + Circle() + .fill(color) + .frame(width: 20, height: 20) + + HStack { + Button("+1") { + counter += 1 + } + .buttonStyle(.bordered) + .font(.caption) + + Button("Close") { + proxy.dismiss() + } + .buttonStyle(.borderless) + .font(.caption) + } + } + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(radius: 4) + } + } + + Toggle("Animate", isOn: $isAnimating) + .padding(.horizontal, 50) + + Spacer() + } + .padding() + .navigationTitle("Update Tests") + .navigationBarTitleDisplayMode(.inline) + } + } +} + +// MARK: - Preview Collection + +#Preview("Main Example") { MenuWithAView_Example() } + +#Preview("Proxy Tests") { + ContextMenuProxyTestView() +} + +#Preview("Update Tests") { + ViewUpdatesTestView() +} diff --git a/Sources/MenuWithAView/Extensions/UIContextMenuInteraction/UIContextMenuInteraction+AccessoryViewWithConfiguration.swift b/Sources/MenuWithAView/Extensions/UIContextMenuInteraction/UIContextMenuInteraction+AccessoryViewWithConfiguration.swift index bb074a0..b622475 100644 --- a/Sources/MenuWithAView/Extensions/UIContextMenuInteraction/UIContextMenuInteraction+AccessoryViewWithConfiguration.swift +++ b/Sources/MenuWithAView/Extensions/UIContextMenuInteraction/UIContextMenuInteraction+AccessoryViewWithConfiguration.swift @@ -10,7 +10,7 @@ import SwiftUI import ContextMenuAccessoryStructs extension UIContextMenuInteraction { - static func accessoryView(configuration: AccessoryItem.Configuration) -> UIView? { + static func accessoryView(configuration: ContextMenuAccessoryConfiguration) -> UIView? { let accessoryViewClassString = ["View", "Accessory", "Menu", "Context", "UI", "_"].reversed().joined() let accessoryViewClass = NSClassFromString(accessoryViewClassString) as? UIView.Type @@ -37,9 +37,11 @@ extension UIContextMenuInteraction { let anchorSelector = NSSelectorFromString(anchorString) if accessoryView.responds(to: anchorSelector) { - let method = class_getInstanceMethod(accessoryViewClass, anchorSelector)! + guard let method = class_getInstanceMethod(accessoryViewClass, anchorSelector) else { + return accessoryView + } let implementation = method_getImplementation(method) - + let type = (@convention(c) (AnyObject, Selector, ContextMenuAccessoryAnchor) -> Void).self let setAnchor = unsafeBitCast(implementation, to: type) setAnchor(accessoryView, anchorSelector, configuration.anchor) diff --git a/Sources/MenuWithAView/Extensions/UIContextMenuInteraction/UIContextMenuInteraction+Swizzle.swift b/Sources/MenuWithAView/Extensions/UIContextMenuInteraction/UIContextMenuInteraction+Swizzle.swift index 04102b5..aebc6ea 100644 --- a/Sources/MenuWithAView/Extensions/UIContextMenuInteraction/UIContextMenuInteraction+Swizzle.swift +++ b/Sources/MenuWithAView/Extensions/UIContextMenuInteraction/UIContextMenuInteraction+Swizzle.swift @@ -9,33 +9,32 @@ import UIKit import SwiftUI extension UIContextMenuInteraction { - private static var needsSwizzle_delegate_getAccessoryViewsForConfiguration: Bool = true - - static func swizzle_delegate_getAccessoryViewsForConfigurationIfNeeded() { - guard needsSwizzle_delegate_getAccessoryViewsForConfiguration else { return } - + private static let swizzleOnce: () = { let originalString = [":", "Configuration", "For", "Views", "Accessory", "get", "_", "delegate", "_"].reversed().joined() let swizzledString = [":", "Configuration", "For", "Views", "Accessory", "get", "_", "delegate", "_", "swizzled"].reversed().joined() - + let originalSelector = NSSelectorFromString(originalString) let swizzledSelector = NSSelectorFromString(swizzledString) - + guard instancesRespond(to: originalSelector), instancesRespond(to: swizzledSelector) else { return } - + let originalMethod = class_getInstanceMethod(UIContextMenuInteraction.self, originalSelector) let swizzledMethod = class_getInstanceMethod(UIContextMenuInteraction.self, swizzledSelector) - + guard let originalMethod, let swizzledMethod else { return } - + method_exchangeImplementations(originalMethod, swizzledMethod) - - needsSwizzle_delegate_getAccessoryViewsForConfiguration = false + }() + + static func swizzle_delegate_getAccessoryViewsForConfigurationIfNeeded() { + _ = swizzleOnce } @objc dynamic func swizzled_delegate_getAccessoryViewsForConfiguration(_ configuration: UIContextMenuConfiguration) -> [UIView] { - if let identifierView = view?.firstSubview(ofType: ContextMenuIdentifierUIView.self) { - + if let identifierView = view?.firstSubview(ofType: AnyContextMenuIdentifierUIView.self), let contentView = identifierView.accessoryView + { + identifierView.interaction = view?.interactions.compactMap({ $0 as? UIContextMenuInteraction }).first contentView.frame.size = contentView.intrinsicContentSize let accessoryView = UIContextMenuInteraction.accessoryView(configuration: identifierView.configuration) diff --git a/Sources/MenuWithAView/Extensions/View/View+ContextMenuAccessories.swift b/Sources/MenuWithAView/Extensions/View/View+ContextMenuAccessories.swift index 61c039c..c45250a 100644 --- a/Sources/MenuWithAView/Extensions/View/View+ContextMenuAccessories.swift +++ b/Sources/MenuWithAView/Extensions/View/View+ContextMenuAccessories.swift @@ -9,20 +9,67 @@ import SwiftUI import UIKit import ContextMenuAccessoryStructs -private struct AccessoryWrapper: View { - let configuration: ContextMenuAccessoryConfiguration - let accessory: () -> AccessoryView +public extension View { + /// Adds an accessory view to instances of `.contextMenu`. + /// + /// > Note: This modifier should be used in combination with `.contextMenu`. + /// + /// - Parameters: + /// - placement: The placement of the accessory relative to the context menu. *(Optional, default: `.center`)* + /// - location: The location where the accessory should appear. *(Optional, default: `.preview`)* + /// - alignment: The alignment of the accessory within its container. *(Optional, default: `.leading`)* + /// - trackingAxis: The axis along which the accessory tracks user interaction. *(Optional, default: `[.xAxis, .yAxis]`)* + /// - accessory: A view builder that creates the accessory view. + /// + /// For more details on default values, see ``ContextMenuAccessoryConfiguration``. + /// + /// Example usage: + /// + /// ```swift + /// Text("Turtle Rock") + /// .padding() + /// .contextMenu { + /// Button(action: {}) { + /// Label("Button", systemImage: "circle") + /// } + /// } + /// .contextMenuAccessory( + /// placement: placement, + /// location: location, + /// alignment: alignment, + /// trackingAxis: .yAxis + /// ) { + /// Text("Accessory View") + /// .font(.title2) + /// .padding(8) + /// .background(Color.blue.opacity(0.6)) + /// .clipShape(RoundedRectangle(cornerRadius: 12)) + /// .padding(16) + /// } + /// ``` + func contextMenuAccessory( + placement: ContextMenuAccessoryPlacement? = nil, + location: ContextMenuAccessoryLocation? = nil, + alignment: ContextMenuAccessoryAlignment? = nil, + trackingAxis: ContextMenuAccessoryTrackingAxis? = nil, + @ViewBuilder accessory: () -> AccessoryView + ) -> some View { + var config = ContextMenuAccessoryConfiguration() + if let placement = placement { config.placement = placement } + if let location = location { config.location = location } + if let alignment = alignment { config.alignment = alignment } + if let trackingAxis = trackingAxis { config.trackingAxis = trackingAxis } - var body: some View { - ContextMenuIdentifierView(accessoryView: { - AccessoryItem(configuration: configuration) { - accessory() - } - }) + let accessory = accessory() + let wrapped = background { + ContextMenuIdentifierView( + configuration: config, + accessory: { _ in accessory } + ) + } + return wrapped } -} -public extension View { /// Adds an accessory view to instances of `.contextMenu`. /// /// > Note: This modifier should be used in combination with `.contextMenu`. @@ -32,7 +79,7 @@ public extension View { /// - location: The location where the accessory should appear. *(Optional, default: `.preview`)* /// - alignment: The alignment of the accessory within its container. *(Optional, default: `.leading`)* /// - trackingAxis: The axis along which the accessory tracks user interaction. *(Optional, default: `[.xAxis, .yAxis]`)* - /// - accessory: A view builder that creates the accessory view. + /// - accessory: A view builder that creates the accessory view using a proxy to the `.contextMenu` interaction that allows dismissal of the view to be triggered. /// /// For more details on default values, see ``ContextMenuAccessoryConfiguration``. /// @@ -65,19 +112,21 @@ public extension View { location: ContextMenuAccessoryLocation? = nil, alignment: ContextMenuAccessoryAlignment? = nil, trackingAxis: ContextMenuAccessoryTrackingAxis? = nil, - @ViewBuilder accessory: @escaping () -> AccessoryView + @ViewBuilder accessory: @escaping (ContextMenuProxy) -> AccessoryView ) -> some View { var config = ContextMenuAccessoryConfiguration() if let placement = placement { config.placement = placement } if let location = location { config.location = location } if let alignment = alignment { config.alignment = alignment } if let trackingAxis = trackingAxis { config.trackingAxis = trackingAxis } - + let wrapped = background { - AccessoryWrapper(configuration: config, accessory: accessory) - .accessibilityHidden(true) + ContextMenuIdentifierView( + configuration: config, + accessory: accessory + ) } - return wrapped.id(config.id) + return wrapped } }