Swift自定義表情鍵盤+錄音

ControlM發表於2019-03-02

老規矩,一圖勝千言。Demo 傳送門 點我就行

Swift自定義表情鍵盤+錄音

執行環境

  • Xcode10
  • swift 4.0

前言

這裡沒有乾貨,也沒有教程,請各位大神手下留情。這個 demo 是平時自己在工作之餘學習 swift 寫的,因為每天學習時間有限所以這個 demo 前後寫了一個月左右,裡面的語法和命名都不是很規範,也沒有做大量的機型和版本測試,整體語法偏向於OC。在寫的期間也查詢了許多資料以及API 的用法,其中有一部分邏輯和 emoji 表情資源是來自於VernonVan的這篇部落格,我也並無抄襲之意,只是單純的去練習和使用swift語法僅此而已。其他素材均來自於iconfont

看圖知意

Swift自定義表情鍵盤+錄音

這個思維導圖展示的是各個子控制元件的層級關係,也包含了部分邏輯。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.im/post/5a6b3f…

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…

相關文章