背景
阿拉伯語(yǔ)適配是一個(gè)比較麻煩的事情,不止在于它文案的適配裹赴,更多的是在于其語(yǔ)言習(xí)慣的變化。由從左到右(LeftToRight)的布局習(xí)慣變?yōu)榱藦挠蚁蜃?RightToLeft)的布局習(xí)慣。
針對(duì)iOS9之后的RTL(RightToLeft簡(jiǎn)稱RTL)適配棋返,系統(tǒng)有一個(gè)官方文檔教你怎么做適配延都。
定制RTL
當(dāng)系統(tǒng)語(yǔ)言切換成RTL語(yǔ)言(如阿拉伯語(yǔ))后,如果App支持這個(gè)語(yǔ)言睛竣,系統(tǒng)會(huì)自動(dòng)幫助App設(shè)置成RTL布局窄潭。但是很多時(shí)候,我們希望自己配置當(dāng)前是否是RTL酵颁,比如App內(nèi)部支持切換App語(yǔ)言嫉你,App語(yǔ)言不一定跟系統(tǒng)語(yǔ)言保持一致,這時(shí)候躏惋,也許系統(tǒng)是英文幽污,App內(nèi)部設(shè)置成了阿拉伯語(yǔ)。我們依然需要變成RTL布局簿姨,系統(tǒng)是不會(huì)幫我們完成這項(xiàng)任務(wù)的距误,我們只有自己來(lái)設(shè)置RTL。
幸運(yùn)的是扁位,iOS9之后系統(tǒng)提供了相應(yīng)的API幫助我們完成定制准潭。
typedef NS_ENUM(NSInteger, UISemanticContentAttribute) {
UISemanticContentAttributeUnspecified = 0,
UISemanticContentAttributePlayback, // for playback controls such as Play/RW/FF buttons and playhead scrubbers
UISemanticContentAttributeSpatial, // for controls that result in some sort of directional change in the UI, e.g. a segmented control for text alignment or a D-pad in a game
UISemanticContentAttributeForceLeftToRight,
UISemanticContentAttributeForceRightToLeft
} NS_ENUM_AVAILABLE_IOS(9_0);
@property (nonatomic) UISemanticContentAttribute semanticContentAttribute NS_AVAILABLE_IOS(9_0);
UIView有一個(gè)semanticContentAttribute的屬性,當(dāng)我們將其設(shè)置成UISemanticContentAttributeForceRightToLeft之后域仇,UIView將強(qiáng)制變?yōu)镽TL布局刑然。當(dāng)然在非RTL語(yǔ)言下,我們需要設(shè)置它為UISemanticContentAttributeForceLeftToRight暇务,來(lái)適配系統(tǒng)是阿拉伯語(yǔ)泼掠,App是其他語(yǔ)言不需要RTL布局的情況。
讓一個(gè)App適配RTL垦细,我們需要給幾乎所有的View都設(shè)置這個(gè)屬性择镇,這種情況下,首先想到的是hook UIView的DESIGNATED_INITIALIZER括改,在里面設(shè)置semanticContentAttribute腻豌。但是這種辦法有坑,WKWebview雖然繼承于UIView嘱能,但是它的setSemanticContentAttribute:會(huì)有問(wèn)題吝梅,會(huì)導(dǎo)致Crash:
這應(yīng)該是系統(tǒng)的坑,為了繞開(kāi)這個(gè)坑焰檩,我們發(fā)現(xiàn)使用[UIView appearance]來(lái)設(shè)置能達(dá)到差不多的效果:
[UIView appearance].semanticContentAttribute = UISemanticContentAttributeForceRightToLeft;
使用[UIView appearance]設(shè)置后憔涉,大部分的View看上去正常了。除了搜索欄析苫。使用[UIView appearance]設(shè)置后兜叨,搜索欄是不生效的穿扳。不過(guò)不用擔(dān)心,我們只需要設(shè)置一下[UISearchBar appearance]即可国旷。
[UISearchBar appearance].semanticContentAttribute = UISemanticContentAttributeForceRightToLeft;
布局
Autolayout
設(shè)置完view的semanticContentAttribute后矛物,如果使用的是Autolayout布局,并且Autolayout下跪但,使用的是leading和trailing履羞,系統(tǒng)會(huì)自動(dòng)幫助我們調(diào)整布局,將其適配RTL屡久。但是如果使用的是left和right忆首,系統(tǒng)是不會(huì)這么做的。
所以為了適配布局被环,我們需要將所有的left,right替換成leading和trailing糙及。
Frame
對(duì)于frame布局,系統(tǒng)就沒(méi)這么友好了筛欢,frame的布局需要我們自己去適配浸锨。 探究RTL的布局,實(shí)際上只是調(diào)整了frame.origin.x版姑,y和size是不會(huì)變的柱搜。而且對(duì)于靜態(tài)view,如果知道了父view的width剥险,是可以直接算出字view RTL下的frame的聪蘸,所以我們封了一個(gè)category,來(lái)滿足大部分靜態(tài)布局的情況
@implementation UIView (HTSRTL)
- (void)setRTLFrame:(CGRect)frame width:(CGFloat)width
{
if (isRTL()) {
if (self.superview == nil) {
NSAssert(0, @"must invoke after have superView");
}
CGFloat x = width - frame.origin.x - frame.size.width;
frame.origin.x = x;
}
self.frame = frame;
}
- (void)setRTLFrame:(CGRect)frame
{
[self setRTLFrame:frame width:self.superview.frame.size.width];
}
- (void)resetFrameToFitRTL;
{
[self setRTLFrame:self.frame];
}
@end
對(duì)于已經(jīng)完成frame布局的view炒嘲,我們只需要在最后對(duì)view調(diào)用resetFrameToFitRTL宇姚,即可適配RTL。
整體上夫凸,frame適配RTL還是比autolayout麻煩很多。所以對(duì)于新代碼阱持,我們團(tuán)隊(duì)中約定夭拌,布局盡量使用autolayout。除非一些非常特殊的情況衷咽,比如需要考慮性能鸽扁。
手勢(shì)
滑動(dòng)返回
RTL下,除了布局需要調(diào)整镶骗,手勢(shì)的方向也是需要調(diào)整的
正常的滑動(dòng)返回手勢(shì)是右滑桶现,在RTL下,是需要變成左滑返回的鼎姊。為了讓滑動(dòng)返回也適配RTL骡和,我們需要修改navigationBar和UINavigationController.view的semanticContentAttribute相赁。使用[UIView appearance]修改semanticContentAttribute并不能使手勢(shì)隨之改變,我們需要手動(dòng)修改慰于。為了讓所有的UINavigationController都生效钮科。我們hook了UINavigationController的initWithNibName:bundle:
+ (void)load
{
[self hts_swizzleMethod:@selector(initWithNibName:bundle:) withMethod:@selector(rtl_initWithNibName:bundle:)];
}
- (instancetype)rtl_initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil
{
if ([self rtl_initWithNibName:nibNameOrNil bundle:nibBundleOrNil]) {
if (@available(iOS 9.0, *)) {
self.navigationBar.semanticContentAttribute = [UIView appearance].semanticContentAttribute;
self.view.semanticContentAttribute = [UIView appearance].semanticContentAttribute;
}
}
return self;
}
在所有的UINavigationController創(chuàng)建時(shí),我們?cè)O(shè)置了navigationBar和UINavigationController.view的semanticContentAttribute婆赠。這樣系統(tǒng)的手勢(shì)就可以適配RTL了绵脯。
其他手勢(shì)
跟方向有關(guān)的手勢(shì)有2個(gè):UISwipeGestureRecognizer和UIPanGestureRecognizer
UIPanGestureRecognizer是無(wú)法直接設(shè)置有效方向的。為了設(shè)置只對(duì)某個(gè)方向有效休里,一般都是通過(guò)實(shí)現(xiàn)它的delegate中的gestureRecognizerShouldBegin:方法蛆挫,來(lái)指定是否生效。對(duì)于這種情況妙黍,我們只能手動(dòng)修gestureRecognizerShouldBegin:中的邏輯悴侵,來(lái)適配RTL
UISwipeGestureRecognizer有一個(gè)direction的屬性,可以設(shè)置有效方向废境。為了適配RTL畜挨,我們可以hook它的setter方法,達(dá)到自動(dòng)適配的目的:
@implementation UISwipeGestureRecognizer (HTSRTL)
+ (void)load
{
[self hts_swizzleMethod:@selector(setDirection:) withMethod:@selector(rtl_setDirection:)];
}
- (void)rtl_setDirection:(UISwipeGestureRecognizerDirection)direction
{
if (isRTL()) {
if (direction == UISwipeGestureRecognizerDirectionRight) {
direction = UISwipeGestureRecognizerDirectionLeft;
} else if (direction == UISwipeGestureRecognizerDirectionLeft) {
direction = UISwipeGestureRecognizerDirectionRight;
}
}
[self rtl_setDirection:direction];
}
@end
圖片鏡像
在RTL下噩凹,某些圖片是需要鏡像的巴元,比如帶箭頭的返回按鈕。正常情況下驮宴,箭頭是朝左的逮刨,RTL下,箭頭就需要鏡像成朝右堵泽。系統(tǒng)對(duì)這種情況提供了一個(gè)鏡像的方法:
// Creates a version of this image that, when assigned to a UIImageView’s image property, draws its underlying image contents horizontally mirrored when running under a right-to-left language. Affects the flipsForRightToLeftLayoutDirection property; does not affect the imageOrientation property.
- (UIImage *)imageFlippedForRightToLeftLayoutDirection NS_AVAILABLE_IOS(9_0);
然而....這個(gè)方法并不好用修己。通過(guò)切換系統(tǒng)語(yǔ)言,來(lái)適配RTL應(yīng)該是沒(méi)問(wèn)題的迎罗。但是在App內(nèi)部切換語(yǔ)言睬愤,手動(dòng)修改RTL布局,系統(tǒng)的這個(gè)方法就經(jīng)常出現(xiàn)錯(cuò)誤鏡像的情況纹安。無(wú)奈尤辱,我們只好自己寫(xiě)一個(gè)方法,來(lái)達(dá)到這個(gè)目的:
@implementation UIImage (HTSFlipped)
- (UIImage *)hts_imageFlippedForRightToLeftLayoutDirection
{
if (isRTL()) {
return [UIImage imageWithCGImage:self.CGImage
scale:self.scale
orientation:UIImageOrientationUpMirrored];
}
return self;
}
@end
對(duì)于需要在RTL下鏡像的圖片厢岂,手動(dòng)對(duì)image調(diào)用hts_imageFlippedForRightToLeftLayoutDirection即可
UIEdgeInsets
UI上跟左右方向有關(guān)的還有UIEdgeInsets光督,特別是UIButton的imageEdgeInsets和titleEdgeInsets。正常的時(shí)候塔粒,我們?cè)O(shè)置一個(gè)titleEdgeInsets的left结借。但是當(dāng)RTL的情況下,因?yàn)樗械臇|西都左右鏡像了卒茬,應(yīng)該設(shè)置titleEdgeInsets的right布局才會(huì)正常船老。然而系統(tǒng)卻不會(huì)自動(dòng)幫我們將left和right調(diào)換咖熟。我們需要手動(dòng)去適配它。
為了快速適配努隙,我們hook了UIButton的setContentEdgeInsets球恤,setImageEdgeInsets,setTitleEdgeInsets方法在RTL情況下荸镊,手動(dòng)調(diào)換left <-> right咽斧。
UIEdgeInsets RTLEdgeInsetsWithInsets(UIEdgeInsets insets) {
if (insets.left != insets.right && isRTL()) {
CGFloat temp = insets.left;
insets.left = insets.right;
insets.right = temp;
}
return insets;
}
@implementation UIButton (HTSRTL)
+ (void)load
{
RTLMethodSwizzling(self, @selector(setContentEdgeInsets:), @selector(rtl_setContentEdgeInsets:));
RTLMethodSwizzling(self, @selector(setImageEdgeInsets:), @selector(rtl_setImageEdgeInsets:));
RTLMethodSwizzling(self, @selector(setTitleEdgeInsets:), @selector(rtl_setTitleEdgeInsets:));
}
- (void)rtl_setContentEdgeInsets:(UIEdgeInsets)contentEdgeInsets {
[self rtl_setContentEdgeInsets:RTLEdgeInsetsWithInsets(contentEdgeInsets)];
}
- (void)rtl_setImageEdgeInsets:(UIEdgeInsets)imageEdgeInsets {
[self rtl_setImageEdgeInsets:RTLEdgeInsetsWithInsets(imageEdgeInsets)];
}
- (void)rtl_setTitleEdgeInsets:(UIEdgeInsets)titleEdgeInsets {
[self rtl_setTitleEdgeInsets:RTLEdgeInsetsWithInsets(titleEdgeInsets)];
}
@end
然而我們不可能hook住所有的使用EdgeInsets的地方,我們只對(duì)常用的入口進(jìn)行hook躬存,對(duì)某些不常見(jiàn)的地方张惹,我們也提供是rtl_EdgeInsetsMake方法,用它代替UIEdgeInsetsMake岭洲,進(jìn)行適配
UIEdgeInsets RTLEdgeInsetsMake(CGFloat top, CGFloat left, CGFloat bottom, CGFloat right) {
if (left != right && isRTL()) {
CGFloat temp = left;
left = right;
right = temp;
}
return UIEdgeInsetsMake(top, left, bottom, right);
}
TextAlignment
RTL下textAlignment也是需要調(diào)整的宛逗,官方文檔中默認(rèn)textAlignment是NSTextAlignmentNatural,并且NSTextAlignmentNatural可用自動(dòng)適配RTL
By default, text alignment in iOS is natural; in OS X, it’s left. Using natural text alignment aligns text on the left in a left-to-right language, and automatically mirrors the alignment for right-to-left languages
然而盾剩,情況并沒(méi)有文檔描述的那么好雷激,當(dāng)我們?cè)谙到y(tǒng)內(nèi)切換語(yǔ)言的時(shí)候,系統(tǒng)經(jīng)常會(huì)錯(cuò)誤的設(shè)置textAlignment告私。沒(méi)有辦法屎暇,我們只有自己去適配textAlignment.
以UILabel為例,我們hook它的setter的方法驻粟,根據(jù)當(dāng)前是否是RTL根悼,來(lái)設(shè)置正確的textAlignment,如果UILabel從未調(diào)用setTextAlignment:蜀撑,我們還需要給它一個(gè)正確的默認(rèn)值挤巡。
@implementation UILabel (HTSRTL)
+ (void)load
{
RTLMethodSwizzling(self, @selector(initWithFrame:), @selector(rtl_initWithFrame:));
RTLMethodSwizzling(self, @selector(setTextAlignment:), @selector(rtl_setTextAlignment:));
}
- (instancetype)rtl_initWithFrame:(CGRect)frame
{
if ([self rtl_initWithFrame:frame]) {
self.textAlignment = NSTextAlignmentNatural;
}
return self;
}
- (void)rtl_setTextAlignment:(NSTextAlignment)textAlignment
{
if (isRTL()) {
if (textAlignment == NSTextAlignmentNatural || textAlignment == NSTextAlignmentLeft) {
textAlignment = NSTextAlignmentRight;
} else if (textAlignment == NSTextAlignmentRight) {
textAlignment = NSTextAlignmentLeft;
}
}
[self rtl_setTextAlignment:textAlignment];
}
@end
AttributeString
以UILabel為例,對(duì)于AttributeString酷麦,UILabel的textAlignment是不生效的矿卑,因?yàn)锳ttributeString自帶attributes。為了讓attributeString也能自動(dòng)適配RTL沃饶。我們需要在RTL下粪摘,將Alignment的left和right互換。
attributeString的alignment一般使用NSMutableParagraphStyle設(shè)置绍坝,所以我們首先hook NSMutableParagraphStyle,在setAlignment的時(shí)候設(shè)上正確的alignment:
@implementation NSMutableParagraphStyle (HTSRTL)
+ (void)load
{
RTLMethodSwizzling(self, @selector(setAlignment:), @selector(rtl_setAlignment:));
}
- (void)rtl_setAlignment:(NSTextAlignment)alignment
{
if (isRTL()) {
if (alignment == NSTextAlignmentLeft || alignment == NSTextAlignmentNatural) {
alignment = NSTextAlignmentRight;
} else if (alignment == NSTextAlignmentRight) {
alignment = NSTextAlignmentLeft;
}
}
[self rtl_setAlignment:alignment];
}
@end
然而如果attributeString不設(shè)置ParagraphStyle苔悦,或者ParagraphStyle沒(méi)有調(diào)用setAlignment轩褐,hook是無(wú)效的。
適配這種情況玖详,有2種辦法:
- 一種是hook NSAttributedString的初始化方法把介,在里面給attributeString加上合適的alignment勤讽。
- 一種是hook UILabel的setAttributeString,在里面對(duì)attributeString做處理拗踢。
兩種hook都無(wú)法處理好所有的情況:
- NSAttributedString是類族脚牍,類族是對(duì)外屏蔽真實(shí)class的,我們很難完全覆蓋到所有NSAttributedString的class巢墅,更何況還有NSMutableAttributedString等子類的類族诸狭。
- 可以使用AttributeString的地方非常多,除了UILabel還有UITextView等君纫,這里也無(wú)法處理到所有的情況
基于這種情況驯遇,由于使用AttributeString的地方,90%是UILabel蓄髓,我們最終選擇hook UILabel的setAttributeString:
NSAttributedString *RTLAttributeString(NSAttributedString *attributeString) {
if (attributeString.length == 0) {
return attributeString;
}
NSRange range;
NSDictionary *originAttributes = [attributeString attributesAtIndex:0 effectiveRange:&range];
NSParagraphStyle *style = [originAttributes objectForKey:NSParagraphStyleAttributeName];
if (style && isRTLString(attributeString.string)) {
return attributeString;
}
NSMutableDictionary *attributes = originAttributes ? [originAttributes mutableCopy] : [NSMutableDictionary new];
if (!style) {
NSMutableParagraphStyle *mutableParagraphStyle = [[NSMutableParagraphStyle alloc] init];
mutableParagraphStyle.alignment = NSTextAlignmentLeft;
style = mutableParagraphStyle;
[attributes setValue:mutableParagraphStyle forKey:NSParagraphStyleAttributeName];
}
NSString *string = RTLString(attributeString.string);
return [[NSAttributedString alloc] initWithString:string attributes:attributes];
}
@implementation UILabel (HTSRTL)
+ (void)load
{
RTLMethodSwizzling(self, @selector(setAttributedText:), @selector(rtl_setAttributedText:));
}
- (void)rtl_setAttributedText:(NSAttributedString *)attributedText
{
NSAttributedString *attributeString = RTLAttributeString(attributedText);
[self rtl_setAttributedText:attributeString];
}
@end
Unicode字符串
由于閱讀習(xí)慣的差異(阿拉伯語(yǔ)從右往左閱讀叉庐,其他語(yǔ)言從左往右閱讀),所以字符的排序是不一樣的会喝,普通語(yǔ)言左邊是第一個(gè)字符陡叠,阿拉伯語(yǔ)右邊是第一個(gè)字符。
如果是單純某種文字肢执,不管是阿拉伯語(yǔ)還是英文枉阵,系統(tǒng)都是已經(jīng)幫助我們做好適配了的。然而混排的情況下蔚万,系統(tǒng)的適配是有問(wèn)題的岭妖。對(duì)于一個(gè)string,系統(tǒng)會(huì)用第一個(gè)字符來(lái)決定當(dāng)前是LTR還是RTL反璃。
那么坑來(lái)了昵慌,假設(shè)有一個(gè)這樣的字符串@"小明??? ?? ???????"(翻譯過(guò)來(lái)為:小明關(guān)注了你),在阿拉伯語(yǔ)的情況下淮蜈,由于閱讀順序是從右往左斋攀,我們希望他顯示為@"??? ?? ???????小明"。然而按照系統(tǒng)的適配方案梧田,是永遠(yuǎn)無(wú)法達(dá)到我們期望的淳蔼。
- 如果"小明"放前面,第一個(gè)字符是中文裁眯,系統(tǒng)識(shí)別為L(zhǎng)TR鹉梨,從左往右排序,顯示為@"小明??? ?? ???????"穿稳。
- 如果"小明"放后面存皂,第一個(gè)字符是阿拉伯語(yǔ),系統(tǒng)識(shí)別為RTL,從右往左排序旦袋,依然顯示為@"小明??? ?? ???????"骤菠。
為了適配這種情況,可以在字符串前面加一些不會(huì)顯示的字符疤孕,強(qiáng)制將字符串變?yōu)長(zhǎng)TR或者RTL商乎。
In a few cases, the default behavior produces incorrect results. To handle these cases, the Unicode Bidirectional Algorithm provides a number of invisible characters that can be used to force the correct behavior.
在字符串前面添加"\u202B"表示RTL,加"\u202A"LTR祭阀。為了統(tǒng)一適配剛剛的情況鹉戚,我們hook了UILabel的setText:方法
BOOL isRTLString(NSString *string) {
if ([string hasPrefix:@"\u202B"] || [string hasPrefix:@"\u202A"]) {
return YES;
}
return NO;
}
NSString *RTLString(NSString *string) {
if (string.length == 0 || isRTLString(string)) {
return string;
}
if (isRTL()) {
string = [@"\u202B" stringByAppendingString:string];
} else {
string = [@"\u202A" stringByAppendingString:string];
}
return string;
}
@implementation UILabel (HTSRTL)
+ (void)load
{
RTLMethodSwizzling(self, @selector(setText:), @selector(rtl_setText:));
}
- (void)rtl_setText:(NSString *)text
{
[self rtl_setText:RTLString(text)];
}
@end
這種方法雖然能適配RTL,但是由于修改了原來(lái)字符串柬讨,雖然不會(huì)顯示出來(lái)崩瓤,但是畢竟多加了字符,會(huì)改變?cè)瓉?lái)各個(gè)字符的range位置踩官,當(dāng)我們有特殊邏輯要使用各種range的時(shí)候却桶,可能會(huì)有問(wèn)題,對(duì)于這種特殊的情況蔗牡,無(wú)法做到統(tǒng)一適配颖系,所以只能具體情況具體處理
總結(jié)
至此,大部分的情況都可以適配了辩越。整個(gè)適配過(guò)程嘁扼,盡量使用hook的方式,統(tǒng)一處理黔攒,避免代碼的侵入性趁啸。然而有很多地方只能處理最基本的情況,對(duì)很多特殊case是無(wú)法兼容的督惰,比如textAlignment的處理不傅,無(wú)法覆蓋到所有View。比如Unicode字符串的處理赏胚,某些特殊case下可能會(huì)有坑访娶。對(duì)于這些特殊case,我們?cè)倬唧w處理觉阅。
整體來(lái)說(shuō)崖疤,雖然系統(tǒng)在iOS9之后就支持RTL了,但是因?yàn)槭钦麄€(gè)布局方式都改變典勇,系統(tǒng)也無(wú)法做到盡善盡美劫哼,這個(gè)適配過(guò)程還是有很多坑需要去填。