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

Swift实现NSTextAttachment图片的scaleAspectFit适配需求

问题

需要为用作NSTextAttachment的图片实现类似UIView的contentMode = .scaleAspectFit适配效果,目前预设displaySize后,附件图片显示效果不符合预期,希望图片能按等比例缩放规则适配到设定的displaySize范围内,兼容不同尺寸的图片。

复现步骤

设置附件的displaySize为300x300,但图片无法按scaleAspectFit规则显示:

attachment = RemoteImageTextAttachment(imageURL: fileUrl!, displaySize: CGSize(width: 300, height: 300))
解决方案

要实现该效果,需修改RemoteImageTextAttachment的尺寸计算逻辑与边界返回规则,以下是完整修改后的代码:

public class RemoteImageTextAttachment: NSTextAttachment {
  
  // 附件所属的UILabel
  public weak var label: UILabel?
  
  // 图片的目标显示尺寸,为nil时使用图片原尺寸
  public var displaySize: CGSize?
  
  public var downloadQueue: DispatchQueue?
  public let imageUrl: URL
  
  private weak var textContainer: NSTextContainer?
  private var isDownloading = false
    
  var imageCache = AutoPurgingImageCache()
  
  public init(imageURL: URL, displaySize: CGSize? = nil, downloadQueue: DispatchQueue? = nil) {
    self.imageUrl = imageURL
    self.displaySize = displaySize
    self.downloadQueue = downloadQueue
    super.init(data: nil, ofType: nil)
  }
  
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  // 计算图片按aspect fit规则适配到目标尺寸后的实际大小
  private func aspectFitSize(for imageSize: CGSize, within targetSize: CGSize) -> CGSize {
    guard imageSize.width > 0 && imageSize.height > 0 else { return targetSize }
    
    let widthRatio = targetSize.width / imageSize.width
    let heightRatio = targetSize.height / imageSize.height
    // 取最小缩放比例,保证图片完整显示在目标尺寸内
    let scaleFactor = min(widthRatio, heightRatio)
    
    return CGSize(width: imageSize.width * scaleFactor, height: imageSize.height * scaleFactor)
  }
  
  override public func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect {
    
    // 当有目标显示尺寸且图片已加载时,返回适配后的尺寸
    if let displaySize = displaySize, let image = image {
      let fitSize = aspectFitSize(for: image.size, within: displaySize)
      return CGRect(origin: .zero, size: fitSize)
    }
    
    // 图片未加载但有目标尺寸时,先返回目标尺寸作为占位
    if let displaySize = displaySize {
      return CGRect(origin: .zero, size: displaySize)
    }
    
    // 无目标尺寸时使用图片原尺寸
    if let originalImageSize = image?.size {
      return CGRect(origin: .zero, size: originalImageSize)
    }
    
    // 图片未加载且无目标尺寸时的最小占位,保证触发图片下载逻辑
    let placeholderSize = CGSize(width: 1, height: 1)
    return CGRect(origin: .zero, size: placeholderSize)
  }
  
  override public func image(forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? {
    
    // 优先从缓存取图
    if let cachedImage = imageCache.image(withIdentifier: imageUrl.absoluteString) {
      self.image = cachedImage
      return cachedImage
    }
    
    if let image = image {
      return image
    }
    
    self.textContainer = textContainer
    
    guard !isDownloading else {
      return nil
    }
    
    isDownloading = true
    
    let imageUrl = self.imageUrl
    let downloadQueue = self.downloadQueue ?? DispatchQueue.global()
    downloadQueue.async { [weak self] in
      guard let data = try? Data(contentsOf: imageUrl) else {
        DispatchQueue.main.async {
          self?.isDownloading = false
        }
        return
      }
      
      guard let image = UIImage(data: data) else {
        DispatchQueue.main.async {
          self?.isDownloading = false
        }
        return
      }
      
      // 将下载的图片存入缓存
      self?.imageCache.add(image, withIdentifier: imageUrl.absoluteString)
      
      DispatchQueue.main.async { [weak textContainer] in
        guard let strongSelf = self else { return }
        
        defer {
          strongSelf.isDownloading = false
        }
        
        strongSelf.image = image
        strongSelf.label?.setNeedsDisplay()
        
        // 更新UITextView/NSTextView的布局
        if let layoutManager = strongSelf.textContainer?.layoutManager,
           let ranges = layoutManager.rangesForAttachment(strongSelf) {
          ranges.forEach { range in
            layoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil)
          }
        }
      }
    }
    
    return nil
  }
}

public extension NSLayoutManager {
  func rangesForAttachment(_ attachment: NSTextAttachment) -> [NSRange]? {
    guard let textStorage = textStorage else {
      return nil
    }
    var ranges: [NSRange] = []
    textStorage.enumerateAttribute(.attachment, in: NSRange(location: 0, length: textStorage.length), options: [], using: { (attribute, range, _) in
      
      if let foundAttribute = attribute as? NSTextAttachment, foundAttribute === attachment {
        ranges.append(range)
      }
    })
    
    return ranges
  }
}

关键修改点

  1. 新增aspectFitSize辅助方法:根据图片原尺寸和目标显示尺寸,计算等比例缩放后的实际大小,确保图片完整显示在目标尺寸内,无变形或超出边界。
  2. 调整attachmentBounds逻辑:
    • 图片已加载且有目标尺寸时,返回适配后的尺寸作为附件显示边界
    • 图片未加载时返回目标尺寸作为占位,提升体验
  3. 优化缓存与错误处理:利用现有imageCache避免重复下载,修正原代码中下载失败时未重置isDownloading的问题。

内容的提问来源于stack exchange,提问作者husharoonie

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.08 08:40:35