You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何编辑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自定义菜单选项,步骤如下:

  1. 设置代理并标记附件选中状态

    • 让ViewController遵守UITextViewDelegate,并设置textView.delegate = self
    • 新增属性记录当前是否选中了NSTextAttachment,以及选中的附件位置
  2. 拦截菜单显示逻辑

    • 重写canPerformAction(_:withSender:),判断当前选中的是附件时,控制默认菜单项的显示/隐藏,并添加自定义菜单项
    • 实现textView(_:shouldShowMenuForRange:),判断选中范围是否包含附件,更新选中状态
  3. 处理自定义菜单的触发事件

    • 为自定义菜单项实现对应的处理方法,比如删除图片、调整大小等

修改后的完整代码

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.15 07:27:09