Skip to content

Commit

Permalink
Add EditMenu
Browse files Browse the repository at this point in the history
  • Loading branch information
ktiays committed Aug 13, 2023
1 parent 5aa15ee commit 1a29d1e
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 1 deletion.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ let package = Package(
.target(name: "CyanUtils", dependencies: ["CCyanUtils"]),
.target(name: "CyanCombine"),
.target(name: "CyanConcurrency"),
.target(name: "CyanSwiftUI", dependencies: ["CyanExtensions"]),
.target(name: "CyanSwiftUI", dependencies: ["CyanExtensions", "CyanUtils"]),
.target(name: "CyanUI", dependencies: ["CyanSwiftUI"]),
.target(
name: "CyanKit",
Expand Down
143 changes: 143 additions & 0 deletions Sources/CyanSwiftUI/EditMenu.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//
// Created by ktiays on 2023/8/13.
// Copyright (c) 2023 ktiays. All rights reserved.
//

#if os(iOS)
import SwiftUI
import UIKit
import CyanUtils
import CyanExtensions
import ObjectiveC

@available(iOS 15.0, *)
public struct EditMenuModifier: ViewModifier {
private let items: () -> [EditMenuAction]

public init(@ArrayBuilder<EditMenuAction> items: @escaping () -> [EditMenuAction]) {
self.items = items
}

public func body(content: Content) -> some View {
content.overlay {
GeometryReader { proxy in
Color.clear
.contentShape(Rectangle())
.onTapGesture {
// If empty `onTapGesture` block is not added, the outer `List` will not be able to scroll.
// This may be a bug in SwiftUI.
}
.onLongPressGesture {
let items = items()
if items.isEmpty { return }

let menuController = UIMenuController.shared
let dummyView = editMenuDummyView()
dummyView.removeFromSuperview()
guard let keyWindow = UIApplication.shared.cyan.keyWindow else {
return
}
dummyView.frame = proxy.frame(in: .global)
dummyView.actionHandler = { index in
if index >= items.count { return }
items[index].action()
}
keyWindow.addSubview(dummyView)
dummyView.becomeFirstResponder()

menuController.menuItems = items.enumerated().map { (index, action) in
return .init(
title: action.title ?? "",
action: _DummyView.selector(for: index)
)
}
menuController.showMenu(from: dummyView, rect: dummyView.bounds)
}
}
}
}

private func editMenuDummyView() -> _DummyView {
if let view = UIMenuController.shared.dummyView {
return view
}
let view = _DummyView()
UIMenuController.shared.dummyView = view
return view
}

fileprivate class _DummyView: UIView {
static var key: Void? = nil

override init(frame: CGRect) {
super.init(frame: frame)
self.isUserInteractionEnabled = false
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

static let prefix = "CALL_WITH_INDEX_"
private static let prefixLength = prefix.count

static func selector(for index: Int) -> Selector {
Selector("\(prefix)\(index)")
}

var actionHandler: ((Int) -> Void)?

class func willRespond(to selector: Selector) -> Bool {
NSStringFromSelector(selector).hasPrefix(prefix)
}

override class func resolveInstanceMethod(_ selector: Selector!) -> Bool {
let name = NSStringFromSelector(selector)

// intercept unknown selectors of the form `callWithIndex<int>`
guard name.hasPrefix(prefix), let index = Int(name.dropFirst(prefixLength)) else {
return super.resolveInstanceMethod(selector)
}

// add a new method that calls the handler with the given index
let imp: @convention(block) (Self) -> Void = { instance in
instance.actionHandler?(index)
instance.resignFirstResponder()
}

// types "v@:" -> returns void (v), takes object (@) and selector (:)
return class_addMethod(self, selector, imp_implementationWithBlock(imp), "v@:")
}
}
}

@available(iOS 15.0, *)
private extension UIMenuController {
var dummyView: EditMenuModifier._DummyView? {
set {
objc_setAssociatedObject(self, &EditMenuModifier._DummyView.key, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
get {
objc_getAssociatedObject(self, &EditMenuModifier._DummyView.key) as? EditMenuModifier._DummyView
}
}
}

@available(iOS 15.0, *)
public struct EditMenuAction {
public let title: String?
public let action: () -> Void

public init(_ title: String?, action: @escaping () -> Void) {
self.title = title
self.action = action
}
}

@available(iOS 15.0, *)
public extension View {
func editMenu(@ArrayBuilder<EditMenuAction> _ actions: @escaping () -> [EditMenuAction]) -> some View {
modifier(EditMenuModifier(items: actions))
}
}
#endif
1 change: 1 addition & 0 deletions Sources/CyanSwiftUI/HostingViewReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public struct HostingViewReader<Content>: ViewRepresentable where Content: View
hostingView.translatesAutoresizingMaskIntoConstraints = false

super.init(frame: frame)
hostingView.backgroundColor = .clear

addSubview(hostingView)
NSLayoutConstraint.activate([
Expand Down
45 changes: 45 additions & 0 deletions Sources/CyanUtils/ArrayBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// Created by ktiays on 2023/8/13.
// Copyright (c) 2023 ktiays. All rights reserved.
//

import Foundation

@resultBuilder
public struct ArrayBuilder<Element> {
public static func buildBlock(_ components: Element...) -> [Element] {
components
}

public static func buildBlock(_ componentGroups: [Element]...) -> [Element] {
componentGroups.flatMap { $0 }
}

public static func buildEither(first component: [Element]) -> [Element] {
component
}

public static func buildEither(second component: [Element]) -> [Element] {
component
}

public static func buildOptional(_ component: [Element]?) -> [Element] {
component ?? []
}

public static func buildArray(_ components: [[Element]]) -> [Element] {
components.flatMap { $0 }
}

public static func buildExpression(_ expression: Element) -> [Element] {
[expression]
}

public static func buildLimitedAvailability(_ component: [Element]) -> [Element] {
component
}

public static func buildFinalResult(_ component: [Element]) -> [Element] {
component
}
}
8 changes: 8 additions & 0 deletions Sources/CyanUtils/IndexedCallable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//
// Created by ktiays on 2023/8/13.
// Copyright (c) 2023 ktiays. All rights reserved.
//

import Foundation


0 comments on commit 1a29d1e

Please sign in to comment.