Swift自定义表情键盘+录音

2,181 阅读15分钟

老规矩,一图胜千言。Demo 传送门 点我就行

运行环境

  • Xcode10
  • swift 4.0

前言

这里没有干货,也没有教程,请各位大神手下留情。这个 demo 是平时自己在工作之余学习 swift 写的,因为每天学习时间有限所以这个 demo 前后写了一个月左右,里面的语法和命名都不是很规范,也没有做大量的机型和版本测试,整体语法偏向于OC。在写的期间也查询了许多资料以及API 的用法,其中有一部分逻辑和 emoji 表情资源是来自于VernonVan的这篇博客,我也并无抄袭之意,只是单纯的去练习和使用swift语法仅此而已。其他素材均来自于iconfont

看图知意

这个思维导图展示的是各个子控件的层级关系,也包含了部分逻辑。demo中页面联动和旋转适配未做。

花开两朵各表一枝

从 emoji 说起

demo 整体业务逻辑占很大内容,其他都是子控件的堆叠并没有很高的难度系数,只要处理好控件之间的逻辑关系就能很好的实现动画效果。

在做 emoji 表情的时候还在想怎么实现表情与文字的转换,如:😂 -> [笑哭] 这种形式,因为与服务器进行数据交互将表情作为图片做数据传递是非常不合理的,并且还要考虑到表情与文字之间的相互转化关系,所以demo 中用到的是将 emoji 当中富文本的Attachment属性来处理然后给相应的表情打Tag。来看一下具体代码:

//点击 emoji 事件
func didClickEmoji(with model: MYEmojiModel) {

    guard let image = UIImage.image(name: model.imageName!, path: "emoji") else {
        print("图片找不到")
        return
    }
    // 记录textView光标当前位置
    let selectedRange = self.textView.selectedRange
    // 将 emoji 标记为[name] 这种形式
    let emojiString = "[\(model.emojiDescription!)]"
    // 通过字体大小设置 emoji 大小
    let font = UIFont.systemFont(ofSize: MYTextViewTextFont)
    let emojiHeight = font.lineHeight
    // emoji 图片附件
    let attachment = NSTextAttachment()
    attachment.image = image
    attachment.bounds = .init(x: 0, y: font.descender, width: emojiHeight, height: emojiHeight)
    let attachString = NSAttributedString(attachment: attachment)
    // 将图片附件转为 NSMutableAttributedString
    let emojiAttributedString = NSMutableAttributedString(attributedString: attachString)
    // 将这段文字打上标记,key 自己定义,value 为[name],这样做方便遍历和表情与文字替换
    emojiAttributedString.addAttribute(NSAttributedString.Key(rawValue: MYAddEmojiTag), value: emojiString, range: .init(location: 0, length: attachString.length))
    // 获取输入框中的富文本
    let attributedText = NSMutableAttributedString(attributedString: self.textView.attributedText)
    // 将打好标记的富文本替换到光标位置
    attributedText.replaceCharacters(in: selectedRange, with: emojiAttributedString)
    self.textView.attributedText = attributedText
    self.textView.selectedRange = .init(location: selectedRange.location + emojiAttributedString.length, length: 0)
    // 重新设置 font 是为了避免 emoji 在文字末尾导致光标变小
    self.textView.font = font
    //重新计算文字高度,来做自适应
    self.textViewDidChange(self.textView)
}

因为在 emoji 被点击的时候就被打上相应的tag,value 是对应的文字描述,所以在富文本转字符串时就比较方便了。

//将 string 转为 NSString为了方便做字符串截取
let string = attribute.string as NSString
//遍历富文本,筛选出被打标记的富文本    
attribute.enumerateAttribute(NSAttributedString.Key(rawValue: MYAddEmojiTag), 
in: range, options: NSAttributedString.EnumerationOptions.longestEffectiveRangeNotRequired) { (value, range, stop) in
        if value != nil {
        	// value即 emoji 对应的描述信息
           	let tagString = value as! String
            result = result + tagString
            
        }else{
            let rangString = string.substring(with: range)
            result = result + rangString
        }
}
复制粘贴的实现

通过上面的代码就已经实现文字<=>富文本的相互转换了,因为textView自带复制粘贴功能,而UIPasteboard粘贴板是没有attributedText属性的,当复制或剪切时只能将textView.attributedText转为文字,当粘贴的时候只能将文字转为富文本。因为在emoji 键盘被点击的时候你已经知道 emoji 相对应的文字描述,而如果粘贴为纯文字,那如何知道相对应的 emoji 呢?是的,用的是正则匹配,也是盗用别人的逻辑,但是VernonVan他的工程中的正则表达式是有点瑕疵的。在正则表达式上我做了改进,匹配规则如下:

  • 你好[smile] -> [smile]
  • 你好[smile.png] -> [smile.png]
  • 你好[smile_] -> [smile_]
  • 你好[a[smile]] -> [smile]
  • 你好[][[[smile]] -> []、[smile]

只做了 a-z 下划线和.的匹配,如果想匹配更多内容自己添加规则即可。正则表达式不是很会写,只是尝试着想了这几种规则,想要验证和学习的可以去正则验证网站学习。具体代码实现在工程:Targets->Utils->Keyboard->Resources->MYMatchingEmojiManager文件中

//正则验证网站:https://c.runoob.com/front-end/854 
//表达式: \[([a-z_.])+?\]
let regex = try! NSRegularExpression.init(pattern: "\\[([a-z_.])+?\\]")
//用表达式匹配结果
let results = regex.matches(in: string
        , options: NSRegularExpression.MatchingOptions.reportProgress, range: .init(location: 0, length: string.count))
输入框与表情页切换动画

引用别人的话:“真正的键盘也就是说调起表情键盘时输入框是有光标的,能进行拖拽光标、选中区域等的操作,这样的体验才是与系统键盘一致的。其实系统已经提供好了接口给我们直接使用,UITextViewUITextField都有的inputViewinputAccessoryView就是用来实现自定义键盘的”。但是有一种情况是:如果表情键盘的高度低于系统字体键盘的高度,那么在切换表情键盘与文字键盘的时候是有落差的,这个落差导致textView在回落的过程中,字体键盘瞬间切换表情键盘会有一个间隙把当前页面的内容暴露出来个零点几秒,非常影响美观,而系统的文字键盘高度和 emoji 键盘高度时一致的所以没有这个问题。解决办法我暂时就想起来两种:

  1. textViewkeyboardWillShow通知执行时,将textViewsuperView的高度等于 textView.height + emojiView.height 这样supeView的高度就会很大,这样在回落的过程中就不会显示位移缝隙,还可以为 emoji 视图加向上滚动的动画,这样切换就会更加衔接。
  2. 不用textViewinputView属性,做一个假的 emoji 表情页,微信的键盘就是一个假的,因为当切换到表情页时,textView就失去了响应,光标就消失了,这样就造成了键盘回落而 emoji 键盘向上滚动的效果,我在工程中就是用的这种方式。

无论是文字切换语音、文字切表情、语音切表情或者其他功能的任意切换,都是经过以下方法(具体实现见 demo):

private var keyboardType : MYKeyboardInputViewEnum.KeyboardType = .None {
		//默认没有任何属性,为.None
		//相当于OC中的重写 set 方法
        willSet{
        if keyboardType == newValue {
        //如果将要改变的值与当前值一样,则不做任何操作,即同一种模式
            return
        }
        //不相同则重新赋值
        self.keyboardType = newValue;
        switch newValue {
        //判断哪种模式,处理相应的逻辑,具体实现见工程代码
        case .Emoji:
            break
        case .System:
            break
        case .Funcs:
            break
        case .Record:
            break
        default:
            break
        }
        
    }
}
语音边录边转的实现

语音录制逻辑是这样婶的。

  1. 每点击一次录音按钮便创建一个录音机,创建录音机的同时会创建两个路径:.caf路径和.mp3路径,.caf路径是录音机录制的文件存放路径,.mp3路径则为转换后的文件路径。以及录音机的一些必要参数:

    /// 设置录音格式 默认kAudioFormatLinearPCM
    var formatIDKey : AudioFormatID = kAudioFormatLinearPCM
    /// 设置录音采样率(Hz) 8000/44100/96000(影响音频的质量) 默认 44100
    var sampleRateKey : NSInteger = 44100
    /// 录音通道数  1 或 2 默认为2
    var channelsKey : NSInteger = 2
    /// 线性采样位数  8、16、24、32 默认 16
    var bitDepthKey : NSInteger = 16
    /// 录音的质量 默认QualityMin
    var qualityKey : AVAudioQuality = .min
    
  2. 录制时间为60秒,前1S内为初始化录音机时间,如果1S内取消录制则提示"录音时间太短",执行取消录制方法,删除两个文件;如果没有取消则继续录制,展示录制动画,增加手势滑动效果,增加语音消息呼吸灯动画,当录制完毕后在转换回调中删除录制相对应的.caf文件,抛出转换成功的.mp3文件路径。

  3. 录制成功后,拿到相对应的.mp3文件路径上传到服务器,因为在上传过程必为异步上传(如果为主线程那不就卡了),有可能当前文件未上传成功后续又有文件要上传,所以要记得加锁,加锁,加锁保证数据的安全。demo 中这一部分并没有实现。

  4. 取消发送,则删除两个对应的文件,结束转换

  5. 录制时间到直接发送,上滑取消,声波监测等等。。。

具体实现见demo内Utils->Keyboard->Tool->Recorder文件,边录边转的实现见ConverAudioFile文件,转换是用的lame.framework的三方库。

swift基本语法

常用属性

只读属性(readonly)在OC语法中因为存在.h.m两个文件,所以想暴露给外部使用的接口和方法是全部定义在.h文件中的而 swift 则是全部写在同一个文件中的。如果你想定义一个属性为只读属性:

OC写法

.h文件定义
@property (nonatomic, assign,readonly) BOOL isHidden;
.m文件实现
- (BOOL)isHidden{}

swift写法

//只实现 get 方法
var isHidden : BOOL {
	get{
	return true
	}
}

有时候你需要定义一个属性,外部为只读而内部可以读写,OC是非常好实现的

.h文件定义
@property (nonatomic, assign,readonly) BOOL isHidden;
.m文件实现
@property (nonatomic, assign) BOOL isHidden;
这样就可实现一个外部只读内部读写功能

而 swift 实现方法有很多种,你可以定义一个方法,内部定义一个为private的属性,将这个属性返回出去。还有更简便的写法

//意思是内部实现 set 方法,外部只可调用 get 方法
private(set) var isHidden  = true

设置代理OC 中是这样写的

@protocol MYEmojiProtocolDelegate <NSObject>
//必须实现
- (void) didClickDelete;
@optional 可选实现
- (void) didClickSend;

@end

设置代理属性:

@property (nonatomic, weak) id<MYEmojiProtocolDelegate> delegate;

swift 写法

protocol MYEmojiProtocolDelegate : NSObjectProtocol {
	func didClickDelete()
}

设置代理属性:

weak var pageDelegate : MYEmojiProtocolDelegate?

如果 swift 代理方法想设置成option可选方法,则方法需要加@objc前缀,protocol前也是需要加@objc的,被标识为@objc属性,使得它兼容OC代码,拥有可选方法的协议只能被类遵守而枚举和结构体是不能遵守协议的。还有一种做法就是对协议进行方法扩展:

extension MYEmojiProtocolDelegate {
    //扩展代理的方法是必须实现的
    func didClickSend()  {
        
    }
}

在学习 swift 的时候发现OC中的代理与 swift 中的协议,这是两种不同的概念,我们也知道 swift 是一门面向协议的编程,因为是初学 swift 对其理解还是比较浅的,下面谈谈我对面向协议的理解。

protocol是一些方法或属性的名称,自我理解像是方法和属性(或者属性)的集合。只定义接口或者属性而不实现任何功能,如果某个类型(不是类;类型包括:类,枚举,结构体)想要遵守一个协议,那它需要实现这个协议所定义的所有这些内容。swift 里的protocol不仅可以定义方法还可定义属性,这与OC里的有所不同。

从实现方法说起

举个栗子:为UITableViewcell实现点击事件即:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    //增加个点击调用方法
    didClick()        
}

因为有不同的UITableViewcell的子类都需要实现这个方法,那我们应该怎么做呢?

继承可以很好的解决这个问题,但是缺点是带来耦合性。如果再实现一个呼吸效果呢,就又在Base类中实现相应的代码,很快Base类就变得臃肿,且任何代码都可以写进去,而子类也完全不知道实现了父类的哪些方法。

Extension/Category大家肯定在项目中用到的比较多,也很实用。直接为UITableViewcell写一个扩展,那意味着项目里所有的UITableViewcell对象都可以访问这个方法,如果UICollectionCell也需要上面的方法呢?也写扩展,粘贴复制同样的代码,我们都知道这两个类都继承自UIView,那直接给UIView添加扩展,这样项目中所有继承自UIView的对象都可以访问这个方法,为了一个类就污染了其他对象,因为这些对象根本不需要这些方法。

使用协议解决问题

定义一个protocol

protocol MYCompatible {
    // 定义属性
    //必须明确指定该属性支持的操作:只读(get)或者是可读写(get set)
    // 要用 var 定义属性,即使只有 get 方法
    var name: String {get set}
    var birthday : String {get}
    // 定义方法
    //protocol中的约定方法,当方法中有参数时是不能有默认值的
    func eat(food: String)
    //如果需要改变自身的值,需要在方法前面加mutating关键字
    mutating func changeName(name: String)
}

定一个类或者结构体实现该协议

//遵守协议,实现协议的方法  就上面例子而言,只需要将`UITableviewCell`类遵守协议即可
class MYExtension: MYCompatible {

    var name: String = "xiaoma"
    let birthday: String = "1994"
    
    func eat(food: String = "KFC") {
        
        if food == "KFC" {
            print("好吃")
        } else {
            print("想吃KFC")
        }
    }
  	
    //如果协议中方法有mutating关键字,如果结构体来遵守协议则需要mutating
   func changeName(name: String) {
        self.name = name
    }
}

如果只希望协议只被类class遵守,只需要在定义协议的时候在后面加上AnyObject即可

protocol MYCompatible : AnyObject {
    var name: String {get set}
    ...
}

如果协议中定义了构造函数(init),则实现协议的类必须实现这个构造函数

protocol MYCompatible {
    var name: String {get set}
    var birthday: String {get}
    
    // 定义构造函数
    init(name: String)
}
class MYExtension: MYCompatible {
    var name: String = "xiaoma"
    let birthday: String = "1994"
    
    //如果该类被定义为final 则 required 不写  
    required init(name: String) {
    self.name = name    
        
    }
}
协议扩展

像上面的例子中UITableviewCellUICollectionCell中他们所实现的方法都是一样的,只是两者的类型不同,则没必要定义两个协议,只需要写一个协议即可,这时就可以在协议中使用关联类型associatedtype

public protocol MYCompatible {
    associatedtype MYCompatibleType
    var my : MYCompatibleType { get }
    
}

final class MYExtension: MYCompatible {
    typealias MYCompatibleType = Bool
    var my: MYCompatibleType {
        return true
    }
}

我们知道协议中定义的属性或者方法是不提供实现方式的,我们可以通过协议扩展的形式,在扩展中实现相应的代码:

//定一个协议
public protocol MYCompatible {
    //使用关联类型
    associatedtype MYCompatibleType
    //创建属性 属性类型为关联的协议
    var my : MYCompatibleType { get }
}

//构建一个类,实现协议
public final class MYExtension<Base>: MYCompatible {
    // Base 为泛型
    public let my: Base
    // 构造方法
    public init(_ my:Base) {
        self.my = my
    }
}

给协议添加默认实现,用where关键字对协议做条件限定(where 类型限定) 这里 MYCompatibleType 关联类型,可以是类或者是结构体,如果是结构体可以用 MYCompatibleType == Data 如果是类则可以 MYCompatibleType: UIView

extension MYCompatible where MYCompatibleType : UIView {
    public var width: CGFloat {
        get {
            return my.frame.size.width
        }
        set {
            my.frame.size.width = newValue
        }
    }
}

在想要扩展的类中添加MYExtension 类或者结构体,这个类是继承MYCompatible的协议的,所以就拥有了MYCompatible协议里面默认的实现方法,即刚才那个用 `where` 限定的类型
extension UIView {
    var my: MYExtension <UIView> {
         return MYExtension(my: self)
    }
}

//调用则是
let view = UIView()
view.my.width = 20

我们现在回过头来看看这个扩展协议,首先定义一个名为MYCompatible的协议,然后关联类型associatedtype MYCompatibleType ,定义属性为var my : MYCompatibleType { get }返回的类型为关联的类型,再定义一个类MYExtension <Base>Base为泛型,实现协议,则实现my属性,再构造MYExtension类的init方法,现在对UIView进行扩展

extension UIView {
    var my: MYExtension <UIView> {
        return MYExtension(my: self)
    }
}

现在 UIView 的对象里的属性my就实现了MYCompatible协议,即拥有该协议的方法,因为协议默认是不提供方法的实现的,所以要对协议进行扩展,在扩展的时候使用了where做类型限定,即方法拥有者只能是限定的类型。

为什么使用协议扩展

因为我们项目里有很多地方是对UIViewUIColor等常用类进行extension

  1. 在进行多人开发的时候对同一类型做相同操作是很常有的事,他也写了一个和你命名方式一样的方法,但是他也新建了一个文件,然后你们两个方法就冲突了,然后再进行一顿排查。
  2. 随着需求的增多,你扩展的方法也就更多,然后将这些方法写成工具类,当进行下次开发时可以直接拖进工程中快速使用,但是却与其他人的方法冲突了,很尴尬啊。

上面的协议扩展可以很好的解决这个问题,而且在写法上可以带一个自己的标志,逼格很高。像一些三方库都有这种操作的:view.snp.makeConstraints()imageView.kf.setImage(with: <>)

因为类型很多,要扩展出来的方法也很多,总不能每个类或者结构体都写一个协议吧,其实,写一个就够了,将这些协议抽离出一个通用的即可。demo 中就是这样做的,将协议抽离出一个通用的来。

总结

在写的过程中并没有按照别人的代码照抄照搬而是吸取精华,弃去糟粕。写 demo 不是目的,更多的是为了提高自己的知识面,而且 swift 语言版本也日渐稳定,swift 作为 iOS 的新语言潜力还是比较大的。因为对 swift 学习的比较少,理解的也比较浅,文中或 demo 里肯定有不妥当的地方,所以是接受批评和教育的。

转载请说明出处。

参考资料

juejin.cn/post/684490…

www.jianshu.com/p/971fff236…

onevcat.com/2016/11/pop…

onevcat.com/2016/12/pop…

www.jianshu.com/p/c06ebd6de…

www.cnblogs.com/muzijie/p/6…