最近在公司做了個表情鍵盤的需求,這個需求的技術難度不會很大,比較偏向業務。但是要把使用者體驗做的好也是不容易的,其中有幾個點需要特別注意。話不多說,下面開始正文(注:本文對應的Demo放在Github上:github.com/VernonVan/P…)。
市面上的表情鍵盤的分析
首先來看一下市面上主要的幾個APP上的表情鍵盤,平時使用的時候不會去關注細節,這次特意去使用了表情鍵盤,發現各個APP的體驗還是有優有劣的。
首先是QQ和微信,這兩者差不多,切換到表情鍵盤的時候都是沒有游標的,這樣的使用者體驗是非常不好的,沒有辦法在輸入表情的時候框選區域,也不能拖動游標進行特定位置的複製黏貼刪除等操作,微信甚至在輸入框裡顯示的都不是點選的表情圖片,而是文字描述。
接下來看一下微博國際版,國際版調起表情鍵盤時是有游標的,是一個"真正的"鍵盤,但是想要拖拽游標的時候,很大概率上會觸發到儲存圖片的行為(如下圖所示),導致根本沒辦法拖動游標。
同時微博國際版輸入框表情黏貼後的游標定位是錯誤的,如下圖,開始時游標是在第4個表情後面,然後複製狗頭+害羞兩個表情黏貼到游標後,游標還是在第4個表情後,同時黏貼的表情前後都莫名多了空格。
最後是微博,微部落格戶端的表情鍵盤的體驗是非常好的,上面說到的問題都不存在,而且表情鍵盤的刪除按鈕還能長按刪除輸入框的內容。
表情鍵盤的實現
實現效果
主要實現了以下幾個功能
能輸入表情,有游標,支援複製黏貼刪除表情等
長按預覽表情
刪除表情、長按連續刪除表情
適配 iPhone X
基本思路
首先,表情包的圖片是用bundle的形式組織的,用PPSticker
類表徵一套表情包,用PPEmoji
類表徵某一個表情,用一個plist作為配置檔案,儲存表情包的資訊。
PPStickerDataManager
類主要負責資料部分,用單例的形式,這樣可以在初始化的時候只會讀取一次plist檔案中的所有表情資訊;同時我們把輸入框內容發到服務端以及從服務端請求到的都是純文字的,比如會把 "笑死了?" 轉成 "笑死了[笑哭]" 這樣的純文字,而不是直接把表情圖片直接發到服務端,也就是說專案中有大量的地方會有把文字->表情的操作,所以PPStickerDataManager
類也提供匹配某段純文字中的表情,並把文字替換為圖片的功能,PPStickerDataManager
類的標頭檔案如下:
@interface PPStickerDataManager : NSObject
+ (instancetype)sharedInstance;
/// 所有的表情包
@property (nonatomic, strong, readonly) NSArray<PPSticker *> *allStickers;
/* 匹配給定attributedString中的所有emoji,如果匹配到的emoji有本地圖片的話會直接換成本地的圖片
*
* @param attributedString 可能包含表情包的attributedString
* @param font 表情圖片的對齊字型大小
*/
- (void)replaceEmojiForAttributedString:(NSMutableAttributedString *)attributedString font:(UIFont *)font;
@end複製程式碼
"真正的"鍵盤
真正的鍵盤也就是說調起表情鍵盤時輸入框是有游標的,能進行拖拽游標、選中區域等的操作,這樣的體驗才是與系統鍵盤一致的。其實系統已經提供好了介面給我們直接使用,UITextView
和UITextField
都有的inputView
和inputAccessoryView
就是用來實現自定義鍵盤的,這兩個屬性的定義如下:
// Presented when object becomes first responder. If set to nil, reverts to following responder chain. If
// set while first responder, will not take effect until reloadInputViews is called.
@property (nullable, readwrite, strong) UIView *inputView;
@property (nullable, readwrite, strong) UIView *inputAccessoryView;複製程式碼
同時系統鍵盤在 設定->聲音->按鍵音 選項開啟且手機非靜音狀態下輸入是有按鍵的聲音的,這個按鍵音也是可以支援的,只要自定義鍵盤類遵循UIInputViewAudioFeedback
協議,同時實現 enableInputClicksWhenVisible
方法並返回YES,這樣就可以在點選表情的時候呼叫[[UIDevice currentDevice] playInputClick]
方法發出按鍵音了,詳情請檢視蘋果的官方文件。
下面是Demo中鍵盤切換方法的實現:
- (void)changeKeyboardTo:(PPKeyboardType)toType
{
switch (toType) {
case PPKeyboardTypeSystem:
self.textView.inputView = nil; // 切換到系統鍵盤
[self.textView reloadInputViews]; // 呼叫reloadInputViews方法會立刻進行鍵盤的切換
break;
case PPKeyboardTypeSticker:
self.textView.inputView = self.stickerKeyboard; // 切換到自定義的表情鍵盤
[self.textView reloadInputViews];
break;
default:
break;
}
}複製程式碼
去除表情的拖拽互動
在iOS11上,UITextView
上的NSTextAttachment
(表情)預設可以進行拖拽互動,但是卻導致拖動游標時很容易觸發這個互動(圖示可以檢視上面說到的微博國際版中的誤觸)。一番查詢之後才找到一個比較隱蔽的屬性:textDragInteraction
,直接設定為NO
就能禁止掉NSTextAttachment
的拖拽互動。
if (@available(iOS 11.0, *)) { // 只在iOS11及以上才有這個屬性
_textView.textDragInteraction.enabled = NO;
}複製程式碼
與服務端的互動
我們在輸入框中輸入的內容與服務端進行互動的時候都是用純文字的,比如會把 "笑死了?" 轉成 "笑死了[笑哭]" 這樣的純文字發到服務端,而不是直接發表情圖片,向服務端請求內容的時候也是傳回 "笑死了[笑哭]",然後客戶端再根據正則匹配找出表情替換成對應的表情圖片,然後顯示到頁面上。具體過程可以看下圖:
也就是說,我們設定到輸入框的NSAttributedString
中的每一個NSTextAttachment
都有一個"隱藏的"屬性—表情的文字描述,這裡對NSAttributedString
進行擴充就能實現。pp_setTextBackedString
可以對NSAttributedString
的指定range
設定一個PPTextBackedString
型別的屬性,而pp_plainTextForRange
能拿到NSAttributedString
指定range
的純文字。具體實現如下:
@implementation NSAttributedString (PPAddition)
- (NSString *)pp_plainTextForRange:(NSRange)range
{
if (range.location == NSNotFound || range.length == NSNotFound) {
return nil;
}
NSMutableString *result = [[NSMutableString alloc] init];
if (range.length == 0) {
return result;
}
NSString *string = self.string;
[self enumerateAttribute:PPTextBackedStringAttributeName inRange:range options:kNilOptions usingBlock:^(id value, NSRange range, BOOL *stop) {
PPTextBackedString *backed = value;
if (backed && backed.string) {
[result appendString:backed.string];
} else {
[result appendString:[string substringWithRange:range]];
}
}];
return result;
}
@end
@implementation NSMutableAttributedString (PPAddition)
- (void)pp_setTextBackedString:(PPTextBackedString *)textBackedString range:(NSRange)range
{
if (textBackedString && ![NSNull isEqual:textBackedString]) {
[self addAttribute:PPTextBackedStringAttributeName value:textBackedString range:range];
} else {
[self removeAttribute:PPTextBackedStringAttributeName range:range];
}
}
@end複製程式碼
靈活的游標
表情功能,UITextView
都是用NSAttributedString
進行賦值的,並且我們底層其實還是用上面說到的純文字進行實現的,那麼把 [笑死] 轉成 ? 就會從4個字元變成1個字元,這裡是有差值的,如果不處理的話就會出現上面提到的微博國際版中複製黏貼輸入框的表情會導致游標位置不對,甚至莫名其妙多出前後空格的問題。為了精準的定位游標,我們需要自行處理好這些問題。
這裡自己繼承並實現了UITextView
的子類PPStickerTextView
,在這個類中過載複製、黏貼、剪下等操作,分別對應的方法如下:
- (void)cut:(id)sender; // 剪下
- (void)copy:(id)sender; // 複製
- (void)paste:(id)sender; // 黏貼複製程式碼
下面以剪下方法舉例,看看怎麼處理游標的問題,需要注意的地方請看對應的註釋:
- (void)cut:(id)sender
{
// 1.從textView中拿到對應的純文字,比如:笑死了[笑死]
NSString *string = [self.attributedText pp_plainTextForRange:self.selectedRange];
if (string.length) {
// 2. 將純文字寫入到剪貼簿中
[UIPasteboard generalPasteboard].string = string;
// 3. 記住當前的游標位置
NSRange selectedRange = self.selectedRange;
NSMutableAttributedString *attributeContent = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText];
// 4. 將檢測到是表情的文字替換成對應的圖片
[attributeContent replaceCharactersInRange:self.selectedRange withString:@""];
self.attributedText = attributeContent;
// 5. 重新設定游標
self.selectedRange = NSMakeRange(selectedRange.location, 0);
}
}複製程式碼
技術點的分析就是以上這些,詳細的程式碼可以clone下來檢視:github.com/VernonVan/P…