iOS IM实现@某人功能

需求:

  • 长按人头文本输入框填入其昵称;格式:@XXX

  • 可以@多人

  • 用户点击发送后,被@的人会收到推送

思路:

UI:

  • 文本输入框:UITextView

  • @XXX的展示由外部传入对应的昵称 拼接好后制作成图片 交由NSTextAttachment实现

  • UITextView富文本展示,确保用户输入的内容按正常格式展示即可

后台接口关键参数:

// 文本内容
content: String

// @的用户们 每个用户id由","分割
to_uid: String

设计类:

输入框相关

// 接口
/// @某人
/// - Parameters:
///   - name: 用户昵称
///   - uid: 用户ID
public func atPerson(name: String, uid: String) 

/// 评论发送成功,外部调用此方法 主要功能就是收键盘、清理临时数据
public func sendReplySuccess() 

// InputViewDelegate 处理点击发送回调
convenience internal init(maxCount: Int? = nil, placeholder: String = "", delegate: InputViewDelegate) 

// 要用到的UITextView代理方法

public func textViewDidChange(_ textView: UITextView)

public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool 

/// 字典 key存uid value存name 用作at某人时 将key赋值给attachment的accessibilityLabel
/// 发送时 通过key 取到对应的name
private var atPersons: [String: String] = [:] 

/// 是否需要更新输入框文本属性
private var isSetAttr = false 

数据相关:(与本文无太大关系)

// InputViewContentModel

import Foundation
import ObjectMapper

public class InputViewContentModel: Mappable {
    var content: String?
    var pid: String?
    var to_uid: String?
    public required init?(map: Map) {}
    
    public func mapping(map: Map) {
        self.content <- map["content"]
        self.pid <- map["pid"]
        self.to_uid <- map["to_uid"]
    }
} 

1.那么一切从atPerson开始说起:

public func atPerson(name: String, uid: String) {
    if uid.isEmpty {
        return
    }
    // 1.以uid为key name为value 存入atPersons
    atPersons[uid] = "@\(name) "
    // 2.创建NSTextAttachment
    let attachment = NSTextAttachment()
    // 3.获取拼接好后的name的size
    let size = "@\(name) ".getSize(font: .systemFont(ofSize: 14), padding: .zero)
    // 4.创建富文本属性
    let attributes: [NSAttributedStringKey: Any] = [.font : UIFont.systemFont(ofSize: 14)]
    // 5.根据文本和文本大小绘制图片
    if let image = drawImage(with: .clear,
                                        size: CGSize(width: size.width, height: 14),
                                        text: "@\(name) ",
                                        textAttributes: attributes,
                                        circular: false) {
        attachment.image = image
        attachment.bounds = CGRect(origin: .zero, size: image.size)
    }
    // 6.每个用户的uid都是不同的,所以唯一标识就是uid
    attachment.accessibilityLabel = "\(uid)"
    // 7.创建富文本
    let imageAttr = NSMutableAttributedString(attachment: attachment)
    // 8.基准线需要偏移,可以按比例计算,我这里偷懒直接写死
    imageAttr.addAttribute(.baselineOffset, value: -2, range: NSRange(location: 0, length: imageAttr.length))
    // 9.获取当前用户所输入的文本(富文本)我们要插入图片所以需要使用NSMutableAttributedString
    let textAttr = NSMutableAttributedString(attributedString: textview.attributedText)
    // 10.textview.selectedRange:文本框光标当前所在位置 就是我们要把@XXX插入的位置
    textAttr.insert(imageAttr, at: textview.selectedRange.location)
    // 11.重新赋值给文本框
    textview.attributedText = textAttr
    // 12.手动调用一下,textViewDidChange,以便更新行高、更新文本属性等等
    textViewDidChange(textview)
    // 13.显示键盘
    showKeyboard()
}
extension String {
  func getSize(font: UIFont, padding: UIEdgeInsets = .zero)-> CGSize {
    let str = NSString(string: self)
    var size = str.size(withAttributes: [.font : font])
    size.width += (padding.left + padding.right)
    size.height += (padding.top + padding.bottom)
    return size
  }
}
/**
 绘制图片
 
 @param color 背景色
 @param size 大小
 @param text 文字
 @param textAttributes 字体设置
 @param isCircular 是否圆形
 @return 图片
 */
+ (UIImage *)drawImageWithColor:(UIColor *)color
                          size:(CGSize)size
                          text:(NSString *)text
                textAttributes:(NSDictionary<NSAttributedStringKey, id> *)textAttributes
                      circular:(BOOL)isCircular
{
    if (!color || size.width <= 0 || size.height <= 0) return nil;
    CGRect rect = CGRectMake(0, 0, size.width, size.height);
    UIGraphicsBeginImageContextWithOptions(rect.size, NO, 0);
    CGContextRef context = UIGraphicsGetCurrentContext();
    
    // circular
    if (isCircular) {
        CGPathRef path = CGPathCreateWithEllipseInRect(rect, NULL);
        CGContextAddPath(context, path);
        CGContextClip(context);
        CGPathRelease(path);
    }
    
    // color
    CGContextSetFillColorWithColor(context, color.CGColor);
    CGContextFillRect(context, rect);
    
    // text
    CGSize textSize = [text sizeWithAttributes:textAttributes];
    [text drawInRect:CGRectMake((size.width - textSize.width) / 2, (size.height - textSize.height) / 2, textSize.width, textSize.height) withAttributes:textAttributes];
    
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}

2.一旦开始自定义文本样式,你会发现接下来输入的内容Font变小了 因为UITextView默认12,我们设置的14,我们需要保证样式统一。

func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool解决:

public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
  // text.count == 0 表示用户在删除字符 我们不需要更新样式
  isSetAttr = text.count != 0
  
  // 中文键盘使用富文本会出现光标乱动、没有候选词、输入过程中出现英文等问题 
  // 判断中文键盘 更新光标位置即可
  if text.count != 0 {
      if let lang = textView.textInputMode?.primaryLanguage {
          if lang == "zh-Hans" {
              // 中文输入
              // markedTextRange: 当前是否有候选词,没有候选词就更新光标
              if textView.markedTextRange == nil {
                  let range = textView.selectedRange
                  updateTextViewAttribute(textview: textView)
                  textView.selectedRange = range
              }
          }else {
              updateTextViewAttribute(textview: textView)
          }
      }
    // 用户输入回车,就是发送
    if text == "\n" {
        // 简单的判空逻辑,不重要
        if textView.text.trim().isEmpty {
            DWBToast.showCenter(withText: "内容不能为空")
            return false
        }
        // readyToSend就是发送前对富文本的校验了,我们要将attachment展示的图片还原为普通文本 这里的还原不是UI上的还原,而是转为to_uid
        if let model = readyToSend(textView: textView) {
            //发送! 
            delegate?.sendReply(model: model)
        }
        return false
    }
    return true
}

// typingAttributes 将要键入的文本属性。我们用它来使新输入的文本格式与前面统一。
// 网上有很多方式,比如在这里重新给textview.attributeText添加文本样式,但是属性会覆盖。我们的NSAttachment就又歪了
// 而typingAttributes完全符合我们的需求。
private func updateTextViewAttribute(textview: UITextView) {
    guard isSetAttr else{ return }
    var dict = textview.typingAttributes
    dict[NSAttributedStringKey.font.rawValue] = UIFont.systemFont(ofSize: 14)
    dict[NSAttributedStringKey.baselineOffset.rawValue] = 0
    textview.typingAttributes = dict
}

3.文本数据校验和结合

private func readyToSend(textView: UITextView)-> InputViewContentModel? {
    // 检查是否含有@某某
    var uids = [String]()
    let attrs = NSMutableAttributedString(attributedString: textView.attributedText)
    // 遍历文本框富文本所有包含attachment的节点
    attrs.enumerateAttribute(.attachment,
                             in: NSRange(location: 0, length: textview.attributedText.length),
                             options: .longestEffectiveRangeNotRequired) { (attrKey, range, pointer) in
        /*
         1. 取节点
         2. 取节点的标识(uid)
         3. 通过标识取value(name)
         */
        guard
            let attrKey = attrKey as? NSTextAttachment,
            let key = attrKey.accessibilityLabel,
            let value = atPersons[key]
        else {
            return
        }
        // 将转换成to_uid 用作参数
        uids.append(key)
        // attachment无法转换为普通文本
        // 但attachment的内容与value的内容相同
        // 所以将name插入到attachment的位置 以便内容与用户输入的保持一致
        attrs.insert(NSAttributedString(string: value), at: range.location)
    }
    
    // at 去重
    var to_uid = uids.filterDuplicates{$0}
    // at 去除掉资讯号和机器人(业务中他们没有uid)
    to_uid.removeAll {$0 == "0"}
    // 空格 替换成“ “ 安卓端无法识别
    let content = attrs.string.replacingOccurrences(of: "\u{fffc}", with: " ")
    // attrs.string 取字符串,attachment无法被识别 所以内容不会重复
    let data: [String: Any?] = ["content": content,
                                "pid": transmitID,
                                "to_uid": to_uid.joined(separator: ",")]
    // 数据转换。
    return Mapper<InputViewContentModel>().map(JSON: data as [String : Any])
}

4.最后 服务器回调告诉客户端发送成功后 使用sendRelySuceess()

func sendReplySuccess() {
    self.textview.text = ""
    self.textview.attributedText = NSMutableAttributedString(string: "")
    self.textViewDidChange(textview)
    self.atPersons = [:]
    // 收键盘
    // ... 
}

最后:

将@XXX转换为图片 并交由富文本来展示的方式,输出的时候再通过遍历NSAttachment的方式将文本还原,相对来说比较简单,逻辑上也没有难理解的地方。缺点是@XXX除了删除以外没办法编辑。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容