diff --git a/RZRichTextView/Assets/RZRichTextView.bundle/loaderror@3x.png b/RZRichTextView/Assets/RZRichTextView.bundle/loaderror@3x.png new file mode 100644 index 0000000..761ecd7 Binary files /dev/null and b/RZRichTextView/Assets/RZRichTextView.bundle/loaderror@3x.png differ diff --git a/RZRichTextView/Assets/RZRichTextView.bundle/loading.gif b/RZRichTextView/Assets/RZRichTextView.bundle/loading.gif new file mode 100755 index 0000000..b435ffa Binary files /dev/null and b/RZRichTextView/Assets/RZRichTextView.bundle/loading.gif differ diff --git a/RZRichTextView/Classes/LabelRZRich.swift b/RZRichTextView/Classes/LabelRZRich.swift index c3e7113..ed9f896 100644 --- a/RZRichTextView/Classes/LabelRZRich.swift +++ b/RZRichTextView/Classes/LabelRZRich.swift @@ -6,7 +6,6 @@ // import UIKit -import Kingfisher import QuicklySwift public extension UILabel { @@ -45,9 +44,7 @@ public extension UILabel { func fix(attachment: NSTextAttachment, range: NSRange) { if let info = attachment.rzattachmentInfo, let image = info.image { attachment.image = image - var bounds = attachment.bounds - bounds.size = image.size.qscaleto(maxWidth: bounds.width) - attachment.bounds = bounds + attachment.bounds = .init(origin: .zero, size: image.size) } /// 此时富文本如果没有赋值到Label中,表示还在设置Attr guard let attr = self.attributedText else { @@ -57,35 +54,31 @@ public extension UILabel { return } let temp = NSMutableAttributedString(attributedString: attr) - let attach = NSMutableAttributedString(attachment: attachment) - attach.addAttributes(temp.attributes(at: range.location, effectiveRange: nil), range: .init(location: 0, length: attach.length)) + temp.addAttribute(.attachment, value: attachment, range: range) if needPreView { - attach.addAttributes([.rztapLabel: "\(attachment)"], range: .init(location: 0, length: 1)) + temp.addAttributes([.rztapLabel: "\(attachment)"], range: .init(location: 0, length: 1)) } - temp.replaceCharacters(in: range, with: attach) self.attributedText = temp sizeChanged?() } let ats = attr.rt.attachments() + let configure = RZRichTextViewConfigure.shared for at in ats { if let info = at.0.rzattachmentInfo { /// 如果本地已经加载过图片了,则不需要异步即可实现富文本 switch info.type { case .image, .video: - let url = info.poster?.qtoURL ?? info.src?.qtoURL - if let c = RZRichTextViewConfigure.shared.async_imageBy { - let complete: ((String?, UIImage?) -> Void)? = { [weak info] source, image in - info?.image = image - info?.image = UILabel.creatAttachmentInfoView(info, width: max) - fix(attachment: at.0, range: at.1) - } + let complete: ((String?, UIImage?) -> Void)? = { [weak info] source, image in + info?.image = image + let realWidth = (image?.size.width ?? max) + configure.imageViewEdgeInsetsNormal.left + configure.imageViewEdgeInsetsNormal.right + info?.image = UILabel.creatAttachmentInfoView(info, width: min(realWidth, max)) + fix(attachment: at.0, range: at.1) + } + if let _ = info.image { + complete?("", info.image) + } else if let c = configure.async_imageBy { + let url = info.poster?.qtoURL ?? info.src?.qtoURL c(url?.absoluteString, complete) - } else { - UIImage.asyncImageBy(url?.absoluteString) { [weak info] image in - info?.image = image - info?.image = UILabel.creatAttachmentInfoView(info, width: max) - fix(attachment: at.0, range: at.1) - } } case .audio: info.image = UILabel.creatAttachmentInfoView(info, width: max) diff --git a/RZRichTextView/Classes/RZAttachmentInfo.swift b/RZRichTextView/Classes/RZAttachmentInfo.swift index 718d7d7..c1d11af 100644 --- a/RZRichTextView/Classes/RZAttachmentInfo.swift +++ b/RZRichTextView/Classes/RZAttachmentInfo.swift @@ -105,14 +105,17 @@ public extension NSTextAttachment { class func createWithinfo(_ info: RZAttachmentInfo) -> Self { let attachment = Self.init() attachment.rzattachmentInfo = info + let color = RZRichTextViewConfigure.shared.backgroundColor switch info.type { case .image, .video: let size = (info.image?.size ?? .init(width: 1, height: 1)).qscaleto(maxWidth: info.maxWidth) - attachment.image = UIColor.clear.qtoImage(size) + attachment.image = color.qtoImage(size) + attachment.bounds = .init(origin: .zero, size: size) break case .audio: let size = CGSize.init(width: info.maxWidth, height: info.audioViewHeight) - attachment.image = UIColor.clear.qtoImage(size) + attachment.image = color.qtoImage(size) + attachment.bounds = .init(origin: .zero, size: size) break } return attachment diff --git a/RZRichTextView/Classes/RZAttachmentInfoLayerView.swift b/RZRichTextView/Classes/RZAttachmentInfoLayerView.swift index ca43623..ee2fd24 100644 --- a/RZRichTextView/Classes/RZAttachmentInfoLayerView.swift +++ b/RZRichTextView/Classes/RZAttachmentInfoLayerView.swift @@ -7,8 +7,8 @@ import UIKit import QuicklySwift -import Kingfisher import Photos +import Kingfisher /// 操作 public enum RZAttachmentOperation { case none @@ -30,8 +30,6 @@ public protocol RZAttachmentInfoLayerProtocol: NSObjectProtocol { var dispose: NSObject {get set} /// 显示音频文件名 默认true var showAudioName: Bool {get set} - /// 图片或者音频view上下左右边距 - var imageViewEdgeInsets: UIEdgeInsets { get } } open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { public var operation: QuicklySwift.QPublish = .init(value: .none) @@ -45,7 +43,7 @@ open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { audioContent.isHidden = info.type != .audio self.playBtn.isHidden = info.type != .video switch info.type { - case .image: + case .image, .video: if let asset = info.asset { let option = PHImageRequestOptions.init() option.isNetworkAccessAllowed = true @@ -53,23 +51,22 @@ open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { option.deliveryMode = .highQualityFormat PHImageManager.default().requestImageData(for: asset, options: option) { [weak self] data, _, _, _ in if let imageData = data { - self?.imageView.kf.setImage(with: .provider(RawImageDataProvider(data: imageData, cacheKey: asset.localIdentifier))) { [weak self] _ in + self?.imageView.kf.setImage(with: .provider(RawImageDataProvider(data: imageData, cacheKey: asset.localIdentifier))) { _ in self?.updateImageViewSize() } } } - } else if let url = info.src { + } else if let image = info.image { + self.imageView.image = image + self.updateImageViewSize() + } else if let url = (info.poster ?? info.src) { if let c = RZRichTextViewConfigure.shared.async_imageBy { let complete: ((String?, UIImage?) -> Void)? = { [weak self] source, image in self?.imageView.image = image self?.updateImageViewSize() } c(url, complete) - } else { - self.imageView.kf.setImage(with: url.qtoURL, completionHandler: { [weak self] _ in - self?.updateImageViewSize() - }) - } + } } else { info.imagePublish.subscribe({ [weak self] value in guard let self = self else { return } @@ -77,19 +74,11 @@ open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { self.updateImageViewSize() }, disposebag: dispose) } - case .video: - info.imagePublish.subscribe({ [weak self] value in - guard let self = self else { return } - self.imageView.image = value - self.updateImageViewSize() - }, disposebag: dispose) case .audio: if let path = info.path ?? info.src { self.nameLabel.text = path.qtoURL?.lastPathComponent } } - self.layoutIfNeeded() - info.uploadStatus.subscribe({ [weak self] value in switch value { case .idle: @@ -116,6 +105,11 @@ open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { public var canEdit: Bool = true { didSet { contentView.isHidden = !canEdit + let c = RZRichTextViewConfigure.shared + let inset = canEdit ? c.imageViewEdgeInsets : c.imageViewEdgeInsetsNormal + stackView.snp.updateConstraints { make in + make.left.top.right.bottom.equalToSuperview().inset(inset) + } } } public var showAudioName: Bool = true { @@ -125,7 +119,8 @@ open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { } /// 图片视频相关view // 显示的图片 - public var imageView: UIImageView = AnimatedImageView.init().qcontentMode(.scaleAspectFit).qcornerRadius(3, true) + public var imageView: UIImageView = AnimatedImageView().qcontentMode(.scaleAspectFit).qcornerRadius(3, true) + .qimage(RZRichTextViewConfigure.shared.loadingImage) /// 播放按钮 var playBtn: UIButton = .init(type: .custom).qimage(RZRichImage.imageWith("play")).qisUserInteractionEnabled(false) @@ -151,26 +146,21 @@ open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { let contentView: UIView = .init() public var dispose: NSObject = .init() - /// 图片或者音频view上下左右边距 - public var imageViewEdgeInsets: UIEdgeInsets { - return .init(top: 15, left: 3, bottom: 0, right: 15) - } /// 0.0-1.0 public func updateProgress(_ progress: CGFloat) { var bounds = self.progressView.bounds bounds.origin.x = bounds.size.width - bounds.size.width * progress self.progressView.bounds = bounds } - + lazy var stackView = [imageContent, audioContent].qjoined(aixs: .vertical, spacing: 0, align: .fill, distribution: .equalSpacing) public override init(frame: CGRect) { super.init(frame: frame) + /// 测试用 + // self.backgroundColor = RZRichTextViewConfigure.shared.backgroundColor audioPlayBtn.imageView?.contentMode = .scaleAspectFit - let stackView = [imageContent, audioContent].qjoined(aixs: .vertical, spacing: 0, align: .fill, distribution: .equalSpacing) self.qbody([ stackView.qmakeConstraints({ make in - make.left.equalToSuperview().inset(3) - make.top.right.equalToSuperview().inset(15) - make.bottom.lessThanOrEqualToSuperview() + make.left.right.top.bottom.equalToSuperview().inset(RZRichTextViewConfigure.shared.imageViewEdgeInsetsNormal) }), contentView.qmakeConstraints({ make in make.edges.equalToSuperview() @@ -178,7 +168,7 @@ open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { ]) contentView.qbody([ infoLabel.qmakeConstraints({ make in - make.left.bottom.right.equalTo(stackView) + make.left.bottom.right.equalToSuperview() make.height.equalTo(18) }), progressView.qmakeConstraints({ make in @@ -192,8 +182,8 @@ open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { ]) imageContent.qbody([ imageView.qmakeConstraints({ make in - make.top.left.right.equalToSuperview() - make.bottom.lessThanOrEqualToSuperview() + make.top.left.bottom.right.equalToSuperview() + make.width.lessThanOrEqualToSuperview() }), playBtn.qmakeConstraints({ make in make.center.equalToSuperview() @@ -252,10 +242,9 @@ open class RZAttachmentInfoLayerView: UIView, RZAttachmentInfoLayerProtocol { fatalError("init(coder:) has not been implemented") } func updateImageViewSize() { - guard let image = self.imageView.image else { return } - let size = image.size + let size = imageView.image?.size ?? (.init(width: 16.0, height: 9.0)) self.imageView.snp.makeConstraints { make in - make.height.equalTo(self.imageView.snp.width).multipliedBy(size.height / size.width) + make.height.equalTo(self.imageView.snp.width).multipliedBy(size.height / size.width).priority(.high) } } } diff --git a/RZRichTextView/Classes/RZCss.swift b/RZRichTextView/Classes/RZCss.swift index 1166170..c53559a 100644 --- a/RZRichTextView/Classes/RZCss.swift +++ b/RZRichTextView/Classes/RZCss.swift @@ -133,7 +133,20 @@ public extension NSMutableParagraphStyle { public extension [NSAttributedString.Key: Any] { var rz2cssStyle: String { var styletexts:[String] = [] - self.keys.forEach { key in + /// 用数组固定住,是为了每次生成的css内容顺序一致(为了判断是否有修改内容) + let needCodeKeys: [NSAttributedString.Key] = [ +// .paragraphStyle, .strikethroughStyle, .underlineStyle, .attachment, .link, // 外部实现

附件<图片、音频、视频> +// .ligature, .obliqueness, // 不使用 + .font, + .foregroundColor, + .backgroundColor, + .kern, + .strokeWidth, + .shadow, + .baselineOffset, + .expansion, + ] + needCodeKeys.forEach { key in let value = self[key] switch key { case .paragraphStyle, .strikethroughStyle, .underlineStyle, .attachment, .link: break // 外部实现

附件<图片、音频、视频> // 在外部实现 diff --git a/RZRichTextView/Classes/RZHtml.swift b/RZRichTextView/Classes/RZHtml.swift index 269cd76..ba8cf4d 100644 --- a/RZRichTextView/Classes/RZHtml.swift +++ b/RZRichTextView/Classes/RZHtml.swift @@ -7,7 +7,6 @@ import UIKit import QuicklySwift -import Kingfisher public struct RZRichTempAttributedString { let content: NSAttributedString @@ -50,25 +49,18 @@ public extension RZRichTextView { .triming(self.viewModel.spaceRule)])) else { return } + let configure = RZRichTextViewConfigure.shared let attr = NSMutableAttributedString(attributedString: t) func fix(attachment: NSTextAttachment, range: NSRange) { if let info = attachment.rzattachmentInfo, let image = info.image { var bounds = attachment.bounds - let size = image.size.qscaleto(maxWidth: bounds.width) - if bounds.size != size { - bounds.size = size + let inset = self.isEditable ? configure.imageViewEdgeInsets : configure.imageViewEdgeInsetsNormal + let size = image.size.qscaleto(maxWidth: bounds.width - (inset.left + inset.right)) + let realSize = CGSize.init(width: size.width + (inset.left + inset.right), height: size.height + (inset.top + inset.bottom)) + if bounds.size != realSize { + bounds.size = realSize attachment.bounds = bounds - } - /// = 0 表示目前正在设置NSAttributedString, 还没有赋值到textView - guard self.textStorage.length != 0 else { - return - } - let atts = self.textStorage.rt.attachments() - if let a = atts.first(where: {$0.0 == attachment}) { - let attr = NSMutableAttributedString.init(attachment: a.0) - attr.addAttributes(self.textStorage.attributes(at: a.1.location, effectiveRange: nil), range: .init(location: 0, length: attr.length)) - self.textStorage.replaceCharacters(in: a.1, with: attr) - self.fixAttachmentInfo() + self.fixAttachmentInfo(attachment: attachment) } } } @@ -76,6 +68,7 @@ public extension RZRichTextView { for at in ats { if let info = at.0.rzattachmentInfo { if let _ = info.image { + fix(attachment: at.0, range: at.1) continue } switch info.type { @@ -88,14 +81,13 @@ public extension RZRichTextView { fix(attachment: at.0, range: at.1) } c(url, complete) - } else { - UIImage.asyncImageBy(url) { [weak info] image in - info?.image = image - fix(attachment: at.0, range: at.1) - } } } case .audio: + var bounds = at.0.bounds + let inset = self.isEditable ? configure.imageViewEdgeInsets : configure.imageViewEdgeInsetsNormal + bounds.size.height = self.viewModel.audioAttachmentHeight + inset.top + inset.bottom + at.0.bounds = bounds continue } } @@ -285,6 +277,9 @@ public extension String { return nil } var tempHtml = html as NSString + // 在真机中,\" 与 ” 转换没问题 + // 在模拟器中,

这种\" 会导致无法居中 + tempHtml = tempHtml.replacingOccurrences(of: #"\""#, with: #"""#) as NSString var attachments: [RZAttachmentInfo] = [] /// 找音、视频、图片 let regrule = #"(]+)\b.*?>([\s\S]*?))|(]+)\b.*?>([\s\S]*?))|(]+)\b.*?>)"# @@ -403,39 +398,22 @@ public extension String { // tempAttr.replaceCharacters(in: rg, with: "") // } // } - /// 设置附件 + /// 设置附件的图片,附件的bounds,由textView或者UILabel自己实现 + /// 取未编辑状态的配置 + let configure = RZRichTextViewConfigure.shared.imageViewEdgeInsetsNormal let ats : [NSTextAttachment] = tempAttr.rt.attachments().compactMap({$0.0}) ats.enumerated().forEach { idx, at in if let att = attachments[qsafe: idx] { at.rzattachmentInfo = att switch att.type { - case .image: - if let c = RZRichTextViewConfigure.shared.sync_imageBy { - att.image = c(att.src) - } else { - att.image = UIImage.syncImageBy(att.src) - } - case .video: - let src = ((att.poster.qisEmpty ? att.src : att.poster) ?? "") - if let c = RZRichTextViewConfigure.shared.sync_imageBy { - att.image = c(att.src) - } else { - att.image = UIImage.syncImageBy(src) + case .image, .video: + let url = att.poster?.qtoURL ?? att.src?.qtoURL + if let url = url?.absoluteString, let c = RZRichTextViewConfigure.shared.sync_imageBy { + att.image = c(url) } case .audio: - break - } - if let image = att.image { - var bounds = at.bounds - let size = image.size.qscaleto(maxWidth: bounds.width) - if bounds.size != size { - bounds.size = size - at.bounds = bounds - } - } - if att.type == .audio { var bounds = at.bounds - bounds.size.height = att.audioViewHeight + bounds.size.height = att.audioViewHeight + configure.top + configure.bottom at.bounds = bounds } } @@ -468,42 +446,6 @@ public extension String { } } -public extension UIImage { - /// 同步获取本地已下载的图片,如果本地不存在,此时return nil - class func syncImageBy(_ url: String?) -> UIImage? { - guard let url = url?.qtoURL?.absoluteString, !url.isEmpty else { - return nil - } - var image: UIImage? - ImageCache.default.retrieveImage(forKey: url, options: [.loadDiskFileSynchronously]) { res in - image = try? res.get().image - } - return image - } - /// 获取图片, 如果本地已经下载有,则同步返回图片,没得则下载图片 - class func asyncImageBy(_ url: String?, complete: ((_ image: UIImage?) -> Void)?) { - guard let url = url?.qtoURL else { - complete?(nil) - return - } - UIImageView.init().kf.setImage(with: url, options: [.loadDiskFileSynchronously]) { res in - switch res { - case .success(let img): - complete?(img.image) - case .failure(_): - UIImage.qimageByVideoUrl(url.absoluteString) { url, image in - if let image = image { - ImageCache.default.store(image, forKey: url ?? "", options: .init(nil)) - complete?(image) - } else { - complete?(nil) - } - } - } - } - } -} - public class RZLabelInfo { let label: String let range: NSRange @@ -545,7 +487,7 @@ public class RZLabelInfo { } } var templabel: String { - let image = UIColor.qhex(0xffffff, a: 0).qtoImage() + let image = RZRichTextViewConfigure.shared.backgroundColor.qtoImage(.init(width: 16.0, height: 9.0)) let base64 = image?.qtoPngData()?.base64EncodedString() ?? "" return "" } diff --git a/RZRichTextView/Classes/RZRichTextView.swift b/RZRichTextView/Classes/RZRichTextView.swift index fec7941..b19ff17 100644 --- a/RZRichTextView/Classes/RZRichTextView.swift +++ b/RZRichTextView/Classes/RZRichTextView.swift @@ -173,6 +173,7 @@ open class RZRichTextView: UITextView { self.contentInsetAdjustmentBehavior = .never } self.showInputCount() + self.textStorage.delegate = self } required public init?(coder: NSCoder) { @@ -337,6 +338,15 @@ open class RZRichTextView: UITextView { } return v } + /// 为了防止重复修复附件的bounds和UI的位置,这里加上这样的方法,可以节省开销 + var needLayout = false + open override func layoutSubviews() { + super.layoutSubviews() + if needLayout { + needLayout = false + self.updateSubViews() + } + } deinit { /// infoLayer里info有相互引用,所以这里强行移除,用于释放 let vs = self.subviews.filter({$0.tag == 10}) @@ -399,137 +409,12 @@ public extension RZRichTextView { self.contentTextChanged() } } - /// 插入了附件之后,需要fix附件相关的界面 - func fixAttachmentInfo() { - /// 修正一下附件在textView中的位置,当设置了列表的时候,附件显示宽度被压缩,此时重新设置一下附件宽高 - func fixAttachmentBounds() { - self.textStorage.ensureAttributesAreFixed(in: .init(location: 0, length: self.textStorage.length)) - self.layoutIfNeeded() - self.textStorage.enumerateAttribute(.attachment, in: .init(location: 0, length: self.textStorage.length)) { [weak self] value, range, _ in - guard let self = self, let value = value as? NSTextAttachment, let info = value.rzattachmentInfo else { return } - var frame = self.qfistRect(for: range) - /// 某些机型可能会出现无法获取位置,导致计算错误,这个地方使用这个兜底 - if frame.origin.x.isInfinite || frame.origin.x.isNaN { - frame = .init(x: 5, y: 0, width: 0, height: 0) - } - var size: CGSize? - let lineWidth = self.frame.size.width - frame.minX - 5 // 当前行附件可显示的最大宽度 - let edgeinsets = (info.infoLayer.subviews.first { v -> Bool in - if let _ = v as? RZAttachmentInfoLayerProtocol { - return true - } - return false - } as? RZAttachmentInfoLayerProtocol)?.imageViewEdgeInsets ?? .init(top: 15, left: 3, bottom: 0, right: 15) - switch info.type { - case .image, .video: - if let image = info.image { - let imageMaxWidth = lineWidth - (edgeinsets.left + edgeinsets.right) // 图片可显示的最大宽度 - let imageSize = image.size.qscaleto(maxWidth: imageMaxWidth) // 图片真实size - let realSize = CGSize.init(width: imageSize.width + (edgeinsets.left + edgeinsets.right), height: imageSize.height + (edgeinsets.top + edgeinsets.bottom)) // 附件的真实size(是由图片+边距计算得到的) - if abs(frame.size.width - realSize.width) > 5 || abs(frame.size.height - realSize.height) > 10 { - size = realSize - } - } - case .audio: - let h = self.viewModel.audioAttachmentHeight + (edgeinsets.top + edgeinsets.bottom) - if abs(lineWidth - frame.size.width) > 5 || abs(frame.size.height - h) > 10 { - size = .init(width: lineWidth, height: h) - } - } - if let size = size, size.width > 0 { - value.bounds = .init(origin: .zero, size: size) - let attr = NSMutableAttributedString.init(attributedString: .init(attachment: value)) - attr.addAttributes(self.textStorage.attributes(at: range.location, effectiveRange: nil), range: .init(location: 0, length: attr.length)) - let selctedRange = self.selectedRange - self.textStorage.replaceCharacters(in: range, with: attr) - self.layoutIfNeeded() - self.selectedRange = selctedRange - } - } - } - /// 将相关方法延迟加载,是为了在附件绘制完成之后,在获取位置 - func fixAttachmentViewFrame() { - self.textStorage.ensureAttributesAreFixed(in: .init(location: 0, length: self.textStorage.length)) - self.layoutIfNeeded() - var changed = false - /// 将现有的附件额外添加的视图更新位置 - var atts: [(NSTextAttachment, NSRange)] = [] - self.textStorage.enumerateAttribute(.attachment, in: .init(location: 0, length: self.textStorage.length)) { value, range, _ in - if let value = value as? NSTextAttachment { - atts.append((value, range)) - } - } - var needRelaod = false - for att in atts { - let value = att.0 - let range = att.1 - if let info = value.rzattachmentInfo { - let frame = self.rz.rectFor(range: range) ?? .zero - /// 在textView加载内容过程中,执行了此方法的话,frame会无法获取准确数据,所以这里要做一个判断 - if frame.origin.x.isInfinite || frame.origin.x.isNaN || - frame.origin.y.isInfinite || frame.origin.y.isNaN { - needRelaod = true - continue - } - info.infoLayer.frame = frame - if info.infoLayer.superview == nil { - self.addSubview(info.infoLayer) - changed = true - } - if let v = info.infoLayer.subviews.first as? RZAttachmentInfoLayerProtocol { - v.canEdit = self.viewModel.canEdit - v.showAudioName = self.viewModel.showAudioName - } else { - let infoView = RZAttachmentOption.share.viewclass.init() - info.infoLayer.qbody([ - infoView.qmakeConstraints({ make in - make.edges.equalToSuperview().inset(1) - })]) - if let infoView = infoView as? RZAttachmentInfoLayerProtocol { - infoView.info = info - infoView.canEdit = self.viewModel.canEdit - infoView.showAudioName = self.viewModel.showAudioName - self.viewModel.reloadAttachmentInfoIfNeed?(infoView) - } - } - } - } - if needRelaod { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: { - self.fixAttachmentInfo() - }) - } - /// 筛选出已删除的附件,并移除 - let newattachments = self.attachments - let deletedAttachments = lastAttachments.filter { info in - if let _ = newattachments.firstIndex(where: {$0 == info}) { - return false - } - return true - } - deletedAttachments.forEach({ $0.infoLayer.removeFromSuperview() }) - if deletedAttachments.count > 0 { - changed = true - } - /// 记录现有的 - self.lastAttachments = newattachments - if changed { - self.viewModel.attachmentInfoChanged?(newattachments) - } - } - fixAttachmentBounds() - DispatchQueue.main.async { - fixAttachmentViewFrame() - self.fixTextlistNum() - } - } /// 内容改变之后,需要修复附件的位置、以及判断是否超字数 func contentTextChanged() { defer { self.contentChanged?(self) /// 重新校验显示placeholder DispatchQueue.main.async { - self.fixAttachmentInfo() self.showInputCount() self.showPlaceHolder() } @@ -576,7 +461,6 @@ public extension RZRichTextView { value.selected = self.stores.count > 0 } self.accessoryView.reloadData() - self.fixAttachmentInfo() self.showPlaceHolder() } /// fix光标在末尾时的属性 @@ -600,11 +484,12 @@ public extension RZRichTextView { return self.typingAttributes } } +// MARK: - 附件、placeholder、序列号等UI public extension RZRichTextView { + /// 是否显示隐藏placeholder func showPlaceHolder() { DispatchQueue.main.async { - /// 有序无序列表会占用 let show = self.textStorage.length == 0 && self.subviews.filter({$0.isKind(of: RZTextListView.self)}).count == 0 self.qtextViewHelper.placeHolderLabel?.isHidden = !show } @@ -664,6 +549,168 @@ public extension RZRichTextView { } } } +// MARK: - 当编辑过后,需要更新子视图 +extension RZRichTextView: NSTextStorageDelegate { + /// 当编辑有改变时,去刷新子视图 + public func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { + if self.needLayout { + return + } + DispatchQueue.main.async(execute: { + self.needLayout = true + self.setNeedsLayout() + }) + } + /// 需要刷新 + public func setNeedsReLayout() { + needLayout = true + self.setNeedsLayout() + } +} +// MARK: - 更新 附件、序列号等等 +extension RZRichTextView { + /// 插入了附件之后,需要fix附件相关的界面 + func fixAttachmentInfo(attachment: NSTextAttachment? = nil) { + if self.textStorage.length == 0 { return } + if let attachment = attachment { + let medias = self.textStorage.rt.attachments() + if let a = medias.first(where: {$0.0 == attachment}) { + self.textStorage.addAttribute(.attachment, value: attachment, range: a.1) + } + } else { + self.textStorage.addAttributes([:], range: .init(location: 0, length: 1)) + } + } + /// 更新 附件、序列号等等 + private func updateSubViews() { + let medias = self.textStorage.rt.attachments() + /// 记录附件是否需要重新设置位置和大小 + var needReload = false + /// 重新设置附件的bounds(序列等会改变输入位置的宽度,需要需要重置) + func resetAttachmentsBounds() { + for (attachment, range) in medias { + guard let info = attachment.rzattachmentInfo else { continue } + /// 这个frame是包含附件的高度+行高,所以下边真实高度计算时,不能用frame.height + let frame = self.qfistRect(for: range) + if frame.origin.x.isInfinite || frame.origin.x.isNaN { + needReload = true + /// textView 还未布局完成 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { [weak self] in + self?.setNeedsReLayout() + }) + continue + } + var size: CGSize? + let lineWidth = self.frame.size.width - frame.minX - (self.contentInset.right + self.textContainerInset.right) - 5 // 当前行附件可显示的最大宽度 + let c = RZRichTextViewConfigure.shared + /// 得到附件间距 + let edgeinsets: UIEdgeInsets = (self.viewModel.canEdit ? c.imageViewEdgeInsets : c.imageViewEdgeInsetsNormal) + + switch info.type { + case .image, .video: + if let image = info.image { + // 图片可显示的最大宽度 + let imageMaxWidth = lineWidth - (edgeinsets.left + edgeinsets.right) + // 图片真实显示size + let imageSize = image.size.qscaleto(maxWidth: imageMaxWidth) + // 显示附件的view真实size (是由图片+边距计算得到的) + let realSize = CGSize.init(width: imageSize.width + (edgeinsets.left + edgeinsets.right), height: imageSize.height + (edgeinsets.top + edgeinsets.bottom)) + let boundsSize = attachment.bounds.size + if abs(boundsSize.width - realSize.width) >= 1 || abs(boundsSize.height - realSize.height) >= 1 { + size = realSize + } + } + case .audio: + let h = self.viewModel.audioAttachmentHeight + (edgeinsets.top + edgeinsets.bottom) + let boundsSize = attachment.bounds.size + if abs(lineWidth - boundsSize.width) >= 1 || abs(boundsSize.height - h) >= 1 { + size = .init(width: lineWidth, height: h) + } + } + /// size没改变时,则不更新 + if let size = size, size.width > 0 { + attachment.bounds = .init(origin: .zero, size: size) + needReload = true + self.fixAttachmentInfo(attachment: attachment) + } + } + } + /// 当附件bounds没有问题,则更新附件的UI + func resetAttachmentInfo() { + var changed = false + for (attachment, range) in medias { + guard let info = attachment.rzattachmentInfo else { continue } + let frame = self.qfistRect(for: range) + if frame.origin.x.isInfinite || frame.origin.x.isNaN { + needReload = true + /// textView 还未布局完成 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { [weak self] in + self?.setNeedsReLayout() + }) + continue + } + /// frame 包含了附件的高度+行间距,如果直接用frame去设置info的高度,会出现高度不匹配,所以这里位置用frame的,size用attachment的 + info.infoLayer.frame = .init(origin: frame.origin, size: attachment.bounds.size) + + if info.infoLayer.superview == nil { + self.addSubview(info.infoLayer) + changed = true + } + let v = info.infoLayer.subviews.first(where: {($0 as? RZAttachmentInfoLayerProtocol) != nil }) as? RZAttachmentInfoLayerProtocol + if let v = v { + v.canEdit = self.viewModel.canEdit + v.showAudioName = self.viewModel.showAudioName + } else { + /// 将附件显示的UI,加到layer上 + let infoView = RZAttachmentOption.share.viewclass.init() + info.infoLayer.qbody([ + infoView.qmakeConstraints({ make in + make.edges.equalToSuperview() + })]) + if let infoView = infoView as? RZAttachmentInfoLayerProtocol { + infoView.info = info + infoView.canEdit = self.viewModel.canEdit + infoView.showAudioName = self.viewModel.showAudioName + self.viewModel.reloadAttachmentInfoIfNeed?(infoView) + } + } + } + if needReload { + return + } + /// 筛选出已删除的附件,并移除 + let newattachments = self.attachments + let deletedAttachments = lastAttachments.filter { info in + if let _ = newattachments.firstIndex(where: {$0 == info}) { + return false + } + return true + } + deletedAttachments.forEach({ $0.infoLayer.removeFromSuperview() }) + if deletedAttachments.count > 0 { + changed = true + } + /// 记录现有的 + self.lastAttachments = newattachments + if changed { + self.viewModel.attachmentInfoChanged?(newattachments) + } + } + /// 先重设bounds的size + resetAttachmentsBounds() + /// size如果发生改变,则先刷新界面 + if needReload { + return + } + /// 只有bounds都完全正确的时候,才去更新附件的UI + resetAttachmentInfo() + if needReload { + return + } + /// 序列号的处理 + self.fixTextlistNum() + } +} /// 历史记录 open class RZRichHistory { let attr: NSAttributedString diff --git a/RZRichTextView/Classes/RZRichTextViewConfigure.swift b/RZRichTextView/Classes/RZRichTextViewConfigure.swift index 50c2bff..3191438 100644 --- a/RZRichTextView/Classes/RZRichTextViewConfigure.swift +++ b/RZRichTextView/Classes/RZRichTextViewConfigure.swift @@ -6,11 +6,64 @@ // import UIKit +import Kingfisher open class RZRichTextViewConfigure: NSObject { public static var shared: RZRichTextViewConfigure = .init() - /// 同步获取图片 + /// 图片或者音频view上下左右边距(可编辑时) + public var imageViewEdgeInsets: UIEdgeInsets = .init(top: 15, left: 3, bottom: 3, right: 15) + /// 图片或者音频view上下左右边距(不可编辑时,即预览时) + public var imageViewEdgeInsetsNormal: UIEdgeInsets = .init(top: 3, left: 3, bottom: 3, right: 3) + /// 这个颜色将生成一张占位图 + public var backgroundColor: UIColor = .clear + /// 加载失败的图片,gif的话可以参照初始化方法设置 + public var loadErrorImage: UIImage? + /// 加载中的图片,gif的话可以参照初始化方法设置 + public var loadingImage: UIImage? + /// 同步获取图片,可以按需设置请求图片的方法,注意gif、视频首帧图 public var sync_imageBy: ((_ source: String?) -> UIImage?)? - /// 异步获取图片,complete里的source需要于图片对应 + /// 异步获取图片,complete里的source需要于图片对应,可以按需设置请求图片的方法,注意gif、视频首帧图 public var async_imageBy: ((_ source: String?, _ complete: ((_ source: String?, _ image: UIImage?) -> Void)?) -> Void)? + + public override init() { + super.init() + /// 同步获取图片 + self.sync_imageBy = { source in + let imgView = AnimatedImageView() + imgView.kf.setImage(with: source?.qtoURL) + return imgView.image + } + /// 异步获取图片(当图片获取失败时,则用默认的错误图片替代) + self.async_imageBy = { [weak self] source, complete in + let comp = complete + guard let s = source else { + comp?(source, self?.loadErrorImage) + return + } + var imgView : AnimatedImageView? = .init() + imgView?.kf.setImage(with: source?.qtoURL) { result in + let image = try? result.get().image + if image == nil { + /// 图片获取失败,当做视频去请求首帧,并缓存 + UIImage.qimageByVideoUrl(s) { _, image in + if let image = image { + ImageCache.default.store(image, forKey: s) + } + comp?(s, image ?? self?.loadErrorImage) + imgView = nil + } + } else { + comp?(s, image ?? self?.loadErrorImage) + imgView = nil + } + } + } + /// 加载中, 默认gif + let loading: URL? = RZRichImage.imagePathWith("loading.gif") + AnimatedImageView().kf.setImage(with: loading) { [weak self] result in + self?.loadingImage = try? result.get().image + } + /// 加载失败 + self.loadErrorImage = RZRichImage.imageWith("loaderror") + } } diff --git a/RZRichTextView/Classes/RZRichTextViewModel.swift b/RZRichTextView/Classes/RZRichTextViewModel.swift index faad601..20b8134 100644 --- a/RZRichTextView/Classes/RZRichTextViewModel.swift +++ b/RZRichTextView/Classes/RZRichTextViewModel.swift @@ -189,6 +189,29 @@ public class RZRichImage: NSObject { if let imgPath = imgPath { return .init(contentsOfFile: imgPath) } + imgPath = tempbundle.path(forResource: "RZRichTextView.bundle/\(name)", ofType: "") + if let imgPath = imgPath { + return .init(contentsOfFile: imgPath) + } + return nil + } + public class func imagePathWith(_ name: String) -> URL? { + let bundle = Bundle.init(for: Self.self) + guard let url = bundle.url(forResource: "RZRichTextView", withExtension: "bundle") else { return nil } + guard let tempbundle = Bundle.init(url: url) else { return nil } + let fileName = "\(name)@3x" + var imgPath = tempbundle.path(forResource: fileName, ofType: "png") + if let imgPath = imgPath { + return imgPath.qtoURL + } + imgPath = tempbundle.path(forResource: "RZRichTextView.bundle/\(fileName)", ofType: "png") + if let imgPath = imgPath { + return imgPath.qtoURL + } + imgPath = tempbundle.path(forResource: "RZRichTextView.bundle/\(name)", ofType: "") + if let imgPath = imgPath { + return imgPath.qtoURL + } return nil } }