如何编辑UITextView中NSTextAttachment的长按上下文菜单
自定义UITextView中NSTextAttachment的长按菜单
问题描述
我在实现带图片附件的UITextView时,长按NSTextAttachment会弹出默认菜单(包含复制图片、保存到相机胶卷等选项),但想仿照苹果笔记(或Bear这类应用)添加/编辑菜单选项,却找不到访问或修改该菜单的方法,求具体实现方案。
当前默认菜单效果:
期望实现的苹果笔记菜单效果:
我的现有代码:
import UIKit import AVFoundation class ViewController: UIViewController { var textView: UITextView! override func viewDidLoad() { super.viewDidLoad() setUpTextView() loadAttributedText() } func setUpTextView() { textView = UITextView() textView.layer.borderColor = UIColor.black.cgColor textView.layer.borderWidth = 2 textView.alwaysBounceVertical = true textView.keyboardDismissMode = .interactive view.addSubview(textView) // 设置约束 textView.translatesAutoresizingMaskIntoConstraints = false textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true textView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true textView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true } func loadAttributedText() { let attributedText = NSMutableAttributedString(string: "This is a sunflower\n") let image = UIImage(named: "flower")! let compressedImage = image.compressImage(to: CGSize(width: 300, height: 400)) let imageAttachment = NSTextAttachment(image: compressedImage) let attachmentString = NSAttributedString(attachment: imageAttachment) attributedText.append(attachmentString) attributedText.addAttribute(.font, value: UIFont.systemFont(ofSize: 24, weight: .semibold), range: NSRange(location: 0, length: attributedText.length)) textView.textStorage.setAttributedString(attributedText) } } extension UIImage { func compressImage(to maxSize: CGSize) -> UIImage { let availableRect = AVFoundation.AVMakeRect(aspectRatio: self.size, insideRect: .init(origin: .zero, size: maxSize)) let targetSize = availableRect.size let format = UIGraphicsImageRendererFormat() format.scale = UIScreen.main.scale let renderer = UIGraphicsImageRenderer(size: targetSize, format: format) let resized = renderer.image { (context) in self.draw(in: CGRect(origin: .zero, size: targetSize)) } return resized } }
注:代码中UIImage(named: "flower")需要在项目资源中添加同名JPEG图片用于测试。
实现方案
核心思路是通过UITextViewDelegate和重写UIResponder的canPerformAction(_:withSender:)方法,结合UIMenuController自定义菜单选项,步骤如下:
设置代理并标记附件选中状态
- 让
ViewController遵守UITextViewDelegate,并设置textView.delegate = self - 新增属性记录当前是否选中了NSTextAttachment,以及选中的附件位置
- 让
拦截菜单显示逻辑
- 重写
canPerformAction(_:withSender:),判断当前选中的是附件时,控制默认菜单项的显示/隐藏,并添加自定义菜单项 - 实现
textView(_:shouldShowMenuForRange:),判断选中范围是否包含附件,更新选中状态
- 重写
处理自定义菜单的触发事件
- 为自定义菜单项实现对应的处理方法,比如删除图片、调整大小等
修改后的完整代码
import UIKit import AVFoundation class ViewController: UIViewController, UITextViewDelegate { var textView: UITextView! // 记录当前是否选中了图片附件,以及附件的位置范围 private var selectedAttachmentRange: NSRange? override func viewDidLoad() { super.viewDidLoad() setUpTextView() loadAttributedText() } func setUpTextView() { textView = UITextView() textView.layer.borderColor = UIColor.black.cgColor textView.layer.borderWidth = 2 textView.alwaysBounceVertical = true textView.keyboardDismissMode = .interactive textView.delegate = self // 设置代理 view.addSubview(textView) // 设置约束 textView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) ]) } func loadAttributedText() { let attributedText = NSMutableAttributedString(string: "This is a sunflower\n") guard let image = UIImage(named: "flower") else { return } let compressedImage = image.compressImage(to: CGSize(width: 300, height: 400)) let imageAttachment = NSTextAttachment(image: compressedImage) let attachmentString = NSAttributedString(attachment: imageAttachment) attributedText.append(attachmentString) attributedText.addAttribute(.font, value: UIFont.systemFont(ofSize: 24, weight: .semibold), range: NSRange(location: 0, length: attributedText.length)) textView.textStorage.setAttributedString(attributedText) } // MARK: - UITextViewDelegate func textView(_ textView: UITextView, shouldShowMenuForRange range: NSRange) -> Bool { // 检查选中范围是否包含NSTextAttachment selectedAttachmentRange = nil textView.textStorage.enumerateAttribute(.attachment, in: range, options: []) { value, attachmentRange, _ in if let attachment = value as? NSTextAttachment, attachment.image != nil { selectedAttachmentRange = attachmentRange } } // 如果选中了附件,允许显示菜单;否则使用默认逻辑 return selectedAttachmentRange != nil || super.canPerformAction(#selector(UIResponderStandardEditActions.copy(_:)), withSender: nil) } // MARK: - 自定义菜单控制 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { guard let _ = selectedAttachmentRange else { // 未选中附件时,使用默认菜单逻辑 return super.canPerformAction(action, withSender: sender) } // 保留默认的复制、保存到相册选项,添加自定义选项 switch action { case #selector(copy(_:)), #selector(saveImage(_:)): return true case #selector(deleteImage(_:)), #selector(resizeImage(_:)): return true default: // 隐藏其他默认选项(比如粘贴、剪切等) return false } } override func copy(_ sender: Any?) { guard let range = selectedAttachmentRange, let attachment = textView.textStorage.attribute(.attachment, at: range.location, effectiveRange: nil) as? NSTextAttachment, let image = attachment.image else { super.copy(sender) return } // 复制图片到剪贴板 UIPasteboard.general.image = image } // MARK: - 自定义菜单动作 @objc private func saveImage(_ sender: Any?) { guard let range = selectedAttachmentRange, let attachment = textView.textStorage.attribute(.attachment, at: range.location, effectiveRange: nil) as? NSTextAttachment, let image = attachment.image else { return } // 保存到相机胶卷 UIImageWriteToSavedPhotosAlbum(image, self, #selector(imageSaved(_:didFinishSavingWithError:contextInfo:)), nil) } @objc private func deleteImage(_ sender: Any?) { guard let range = selectedAttachmentRange else { return } // 删除选中的图片附件 textView.textStorage.deleteCharacters(in: range) selectedAttachmentRange = nil // 关闭菜单 UIMenuController.shared.setMenuVisible(false, animated: true) } @objc private func resizeImage(_ sender: Any?) { guard let range = selectedAttachmentRange, let attachment = textView.textStorage.attribute(.attachment, at: range.location, effectiveRange: nil) as? NSTextAttachment, let image = attachment.image else { return } // 这里可以实现调整图片大小的逻辑,比如弹出选择框 let resizedImage = image.compressImage(to: CGSize(width: 200, height: 267)) attachment.image = resizedImage // 更新文本内容 textView.textStorage.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment)) selectedAttachmentRange = nil UIMenuController.shared.setMenuVisible(false, animated: true) } // MARK: - 保存图片回调 @objc private func imageSaved(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { if let error = error { let alert = UIAlertController(title: "保存失败", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "确定", style: .default)) present(alert, animated: true) } else { let alert = UIAlertController(title: "保存成功", message: "图片已保存到相机胶卷", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "确定", style: .default)) present(alert, animated: true) } } } extension UIImage { func compressImage(to maxSize: CGSize) -> UIImage { let availableRect = AVFoundation.AVMakeRect(aspectRatio: self.size, insideRect: CGRect(origin: .zero, size: maxSize)) let targetSize = availableRect.size let format = UIGraphicsImageRendererFormat() format.scale = UIScreen.main.scale let renderer = UIGraphicsImageRenderer(size: targetSize, format: format) let resized = renderer.image { context in self.draw(in: CGRect(origin: .zero, size: targetSize)) } return resized } }
关键说明
selectedAttachmentRange用于记录当前选中的图片附件位置,确保菜单动作能精准定位到目标附件- 在
canPerformAction(_:withSender:)中,可自由控制菜单项的显示:保留默认的复制、保存选项,添加删除、调整大小等自定义功能 - 自定义动作方法需要用
@objc标记,才能被菜单识别 - 若需要更复杂的菜单(如子菜单),可使用
UIMenu和UIAction构建,适配iOS 13+的系统菜单风格
内容的提问来源于stack exchange,提问作者FE_Tech
相关产品推荐
相关产品推荐

