├── .gitignore ├── LICENCE ├── Package.swift ├── README.md ├── Sources └── EditMenu │ ├── ArrayBuilder.swift │ ├── EditMenuView.swift │ └── IndexedCallable.swift ├── Tests └── EditMenuTests │ ├── EditMenuTests.swift │ └── XCTestManifests.swift └── recording.gif /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,macos 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpm,xcode,macos 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### Swift ### 34 | # Xcode 35 | # 36 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 37 | 38 | ## User settings 39 | xcuserdata/ 40 | 41 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 42 | *.xcscmblueprint 43 | *.xccheckout 44 | 45 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 46 | build/ 47 | DerivedData/ 48 | *.moved-aside 49 | *.pbxuser 50 | !default.pbxuser 51 | *.mode1v3 52 | !default.mode1v3 53 | *.mode2v3 54 | !default.mode2v3 55 | *.perspectivev3 56 | !default.perspectivev3 57 | 58 | ## Obj-C/Swift specific 59 | *.hmap 60 | 61 | ## App packaging 62 | *.ipa 63 | *.dSYM.zip 64 | *.dSYM 65 | 66 | ## Playgrounds 67 | timeline.xctimeline 68 | playground.xcworkspace 69 | 70 | # Swift Package Manager 71 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 72 | # Packages/ 73 | # Package.pins 74 | # Package.resolved 75 | # *.xcodeproj 76 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 77 | # hence it is not needed unless you have added a package configuration file to your project 78 | # .swiftpm 79 | 80 | .build/ 81 | 82 | # CocoaPods 83 | # We recommend against adding the Pods directory to your .gitignore. However 84 | # you should judge for yourself, the pros and cons are mentioned at: 85 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 86 | # Pods/ 87 | # Add this line if you want to avoid checking in source code from the Xcode workspace 88 | # *.xcworkspace 89 | 90 | # Carthage 91 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 92 | # Carthage/Checkouts 93 | 94 | Carthage/Build/ 95 | 96 | # Accio dependency management 97 | Dependencies/ 98 | .accio/ 99 | 100 | # fastlane 101 | # It is recommended to not store the screenshots in the git repo. 102 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 103 | # For more information about the recommended setup visit: 104 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 105 | 106 | fastlane/report.xml 107 | fastlane/Preview.html 108 | fastlane/screenshots/**/*.png 109 | fastlane/test_output 110 | 111 | # Code Injection 112 | # After new code Injection tools there's a generated folder /iOSInjectionProject 113 | # https://github.com/johnno1962/injectionforxcode 114 | 115 | iOSInjectionProject/ 116 | 117 | ### SwiftPM ### 118 | Packages 119 | xcuserdata 120 | *.xcodeproj 121 | 122 | 123 | ### Xcode ### 124 | # Xcode 125 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 126 | 127 | 128 | 129 | 130 | ## Gcc Patch 131 | /*.gcno 132 | 133 | ### Xcode Patch ### 134 | *.xcodeproj/* 135 | !*.xcodeproj/project.pbxproj 136 | !*.xcodeproj/xcshareddata/ 137 | !*.xcworkspace/contents.xcworkspacedata 138 | **/xcshareddata/WorkspaceSettings.xcsettings 139 | 140 | # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,macos 141 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "EditMenu", 7 | platforms: [ 8 | .iOS(.v13), 9 | ], 10 | products: [ 11 | .library( 12 | name: "EditMenu", 13 | targets: ["EditMenu"]), 14 | ], 15 | dependencies: [], 16 | targets: [ 17 | .target( 18 | name: "EditMenu", 19 | dependencies: []), 20 | .testTarget( 21 | name: "EditMenuTests", 22 | dependencies: ["EditMenu"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EditMenu 2 | 3 | A SwiftUI extension to allow creating custom "Edit Menus" (`UIMenuController`) 4 | 5 | # Example Usage 6 | 7 | ![](recording.gif) 8 | 9 | ```swift 10 | import SwiftUI 11 | import EditMenu 12 | 13 | struct ContentView: View { 14 | @State var showAlert = false 15 | 16 | var body: some View { 17 | HStack { 18 | Text("Hello, world!") 19 | .padding() 20 | .border(Color.white) 21 | .editMenu { 22 | EditMenuItem("Copy") { 23 | print("copy") 24 | } 25 | EditMenuItem("Show Alert") { 26 | showAlert = true 27 | } 28 | } 29 | }.alert(isPresented: $showAlert) { 30 | Alert(title: Text("This is an alert")) 31 | } 32 | } 33 | } 34 | ``` -------------------------------------------------------------------------------- /Sources/EditMenu/ArrayBuilder.swift: -------------------------------------------------------------------------------- 1 | @_functionBuilder 2 | public struct ArrayBuilder { 3 | public static func buildBlock() -> [T] { [] } 4 | public static func buildBlock(_ expression: T) -> [T] { [expression] } 5 | public static func buildBlock(_ elements: T...) -> [T] { elements } 6 | public static func buildBlock(_ elementGroups: [T]...) -> [T] { elementGroups.flatMap { $0 } } 7 | public static func buildBlock(_ elements: [T]) -> [T] { elements } 8 | public static func buildEither(first: [T]) -> [T] { first } 9 | public static func buildEither(second: [T]) -> [T] { second } 10 | public static func buildIf(_ element: [T]?) -> [T] { element ?? [] } 11 | public static func buildBlock(_ element: Never) -> [T] {} 12 | } 13 | -------------------------------------------------------------------------------- /Sources/EditMenu/EditMenuView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | public struct EditMenuItem { 5 | public let title: String 6 | public let action: () -> Void 7 | 8 | public init(_ title: String, action: @escaping () -> Void) { 9 | self.title = title 10 | self.action = action 11 | } 12 | } 13 | 14 | public extension View { 15 | /// Attaches a long-press action to this `View` withe the given item titles & actions 16 | public func editMenu(@ArrayBuilder _ items: () -> [EditMenuItem]) -> some View { 17 | EditMenuView(content: self, items: items()) 18 | .fixedSize() 19 | } 20 | } 21 | 22 | public struct EditMenuView: UIViewControllerRepresentable { 23 | public typealias Item = EditMenuItem 24 | 25 | public let content: Content 26 | public let items: [Item] 27 | 28 | public func makeCoordinator() -> Coordinator { 29 | Coordinator(items: items) 30 | } 31 | 32 | public func makeUIViewController(context: Context) -> UIHostingController { 33 | let coordinator = context.coordinator 34 | 35 | // `handler` dispatches calls to each item's action 36 | let hostVC = HostingController(rootView: content) { [weak coordinator] index in 37 | guard let items = coordinator?.items else { return } 38 | 39 | if !items.indices.contains(index) { 40 | assertionFailure() 41 | return 42 | } 43 | 44 | items[index].action() 45 | } 46 | 47 | coordinator.responder = hostVC 48 | 49 | let longPress = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.longPress)) 50 | hostVC.view.addGestureRecognizer(longPress) 51 | 52 | return hostVC 53 | } 54 | 55 | public func updateUIViewController(_ uiViewController: UIHostingController, context: Context) { 56 | 57 | } 58 | 59 | public class Coordinator: NSObject { 60 | let items: [Item] 61 | var responder: UIResponder? 62 | 63 | init(items: [Item]) { 64 | self.items = items 65 | } 66 | 67 | @objc func longPress(_ gesture: UILongPressGestureRecognizer) { 68 | let menu = UIMenuController.shared 69 | 70 | guard gesture.state == .began, let view = gesture.view, !menu.isMenuVisible else { 71 | return 72 | } 73 | 74 | // tell `responder` (the `HostingController`) to become first responder 75 | responder?.becomeFirstResponder() 76 | 77 | // each menu item sends a message selector to `responder` based on the index of the item 78 | menu.menuItems = items.enumerated().map { index, item in 79 | UIMenuItem(title: item.title, action: IndexedCallable.selector(for: index)) 80 | } 81 | 82 | // show the menu from the root view 83 | menu.showMenu(from: view, rect: view.bounds) 84 | } 85 | } 86 | 87 | /// Subclass of `UIHostingController` to handle responder actions 88 | class HostingController: UIHostingController { 89 | private var callable: IndexedCallable? 90 | 91 | convenience init(rootView: Content, handler: @escaping (Int) -> Void) { 92 | self.init(rootView: rootView) 93 | 94 | // make sure this VC is sized to its' content 95 | preferredContentSize = view.intrinsicContentSize 96 | 97 | callable = IndexedCallable(handler: handler) 98 | } 99 | 100 | override var canBecomeFirstResponder: Bool { 101 | true 102 | } 103 | 104 | override func responds(to aSelector: Selector!) -> Bool { 105 | return super.responds(to: aSelector) || IndexedCallable.willRespond(to: aSelector) 106 | } 107 | 108 | // forward valid selectors to `callable` 109 | override func forwardingTarget(for aSelector: Selector!) -> Any? { 110 | guard IndexedCallable.willRespond(to: aSelector) else { 111 | return super.forwardingTarget(for: aSelector) 112 | } 113 | 114 | return callable 115 | } 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /Sources/EditMenu/IndexedCallable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Responds to selectors of the form `callWithIndex` by invoking `handler` with the given `int` 4 | class IndexedCallable: NSObject { 5 | static let prefix = "callWithIndex" 6 | private static let prefixLength = prefix.count 7 | 8 | static func selector(for index: Int) -> Selector { 9 | Selector("\(prefix)\(index)") 10 | } 11 | 12 | let handler: (Int) -> Void 13 | 14 | init(handler: @escaping (Int) -> Void) { 15 | self.handler = handler 16 | } 17 | 18 | class func willRespond(to selector: Selector) -> Bool { 19 | NSStringFromSelector(selector).hasPrefix(prefix) 20 | } 21 | 22 | override class func resolveInstanceMethod(_ selector: Selector!) -> Bool { 23 | let name = NSStringFromSelector(selector) 24 | 25 | // intercept unknown selectors of the form `callWithIndex` 26 | guard name.hasPrefix(prefix), let index = Int(name.dropFirst(prefixLength)) else { 27 | return super.resolveInstanceMethod(selector) 28 | } 29 | 30 | // add a new method that calls the handler with the given index 31 | let imp: @convention(block) (IndexedCallable) -> Void = { instance in 32 | instance.handler(index) 33 | } 34 | 35 | // types "v@:" -> returns void (v), takes object (@) and selector (:) 36 | return class_addMethod(self, selector, imp_implementationWithBlock(imp), "v@:") 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /Tests/EditMenuTests/EditMenuTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import EditMenu 3 | 4 | final class EditMenuTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(EditMenu().text, "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Tests/EditMenuTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(EditMenuTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /recording.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jayrhynas/EditMenu/c8537a143063fe761747ff272f45408117f2bcda/recording.gif --------------------------------------------------------------------------------