RTL适配历程
背景
阿拉伯语适配是一个比较麻烦的事情,不止在于它文案的适配,更多的是在于其语言习惯的变化。由从左到右(LeftToRight)的布局习惯变为了从右向左(RightToLeft)的布局习惯。
针对iOS9之后的RTL(RightToLeft简称RTL)适配,系统有一个官方文档教你怎么做适配。
定制RTL
当系统语言切换成RTL语言(如阿拉伯语)后,如果App支持这个语言,系统会自动帮助App设置成RTL布局。但是很多时候,我们希望自己配置当前是否是RTL,比如App内部支持切换App语言,App语言不一定跟系统语言保持一致,这时候,也许系统是英文,App内部设置成了阿拉伯语。我们依然需要变成RTL布局,系统是不会帮我们完成这项任务的,我们只有自己来设置RTL。
幸运的是,iOS9之后系统提供了相应的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有一个semanticContentAttribute的属性,当我们将其设置成UISemanticContentAttributeForceRightToLeft之后,UIView将强制变为RTL布局。当然在非RTL语言下,我们需要设置它为UISemanticContentAttributeForceLeftToRight,来适配系统是阿拉伯语,App是其他语言不需要RTL布局的情况。
让一个App适配RTL,我们需要给几乎所有的View都设置这个属性,这种情况下,首先想到的是hook UIView的DESIGNATED_INITIALIZER,在里面设置semanticContentAttribute。但是这种办法有坑,WKWebview虽然继承于UIView,但是它的setSemanticContentAttribute:会有问题,会导致Crash:
wkcrash.png这应该是系统的坑,为了绕开这个坑,我们发现使用[UIView appearance]来设置能达到差不多的效果:
[UIView appearance].semanticContentAttribute = UISemanticContentAttributeForceRightToLeft;
使用[UIView appearance]设置后,大部分的View看上去正常了。除了搜索栏。使用[UIView appearance]设置后,搜索栏是不生效的。不过不用担心,我们只需要设置一下[UISearchBar appearance]即可。
[UISearchBar appearance].semanticContentAttribute = UISemanticContentAttributeForceRightToLeft;
布局
Autolayout
设置完view的semanticContentAttribute后,如果使用的是Autolayout布局,并且Autolayout下,使用的是leading和trailing,系统会自动帮助我们调整布局,将其适配RTL。但是如果使用的是left和right,系统是不会这么做的。
所以为了适配布局,我们需要将所有的left,right替换成leading和trailing。
Frame
对于frame布局,系统就没这么友好了,frame的布局需要我们自己去适配。 探究RTL的布局,实际上只是调整了frame.origin.x,y和size是不会变的。而且对于静态view,如果知道了父view的width,是可以直接算出字view RTL下的frame的,所以我们封了一个category,来满足大部分静态布局的情况
@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
对于已经完成frame布局的view,我们只需要在最后对view调用resetFrameToFitRTL,即可适配RTL。
整体上,frame适配RTL还是比autolayout麻烦很多。所以对于新代码,我们团队中约定,布局尽量使用autolayout。除非一些非常特殊的情况,比如需要考虑性能。
手势
滑动返回
RTL下,除了布局需要调整,手势的方向也是需要调整的
正常的滑动返回手势是右滑,在RTL下,是需要变成左滑返回的。为了让滑动返回也适配RTL,我们需要修改navigationBar和UINavigationController.view的semanticContentAttribute。使用[UIView appearance]修改semanticContentAttribute并不能使手势随之改变,我们需要手动修改。为了让所有的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创建时,我们设置了navigationBar和UINavigationController.view的semanticContentAttribute。这样系统的手势就可以适配RTL了。
其他手势
跟方向有关的手势有2个:UISwipeGestureRecognizer和UIPanGestureRecognizer
UIPanGestureRecognizer是无法直接设置有效方向的。为了设置只对某个方向有效,一般都是通过实现它的delegate中的gestureRecognizerShouldBegin:方法,来指定是否生效。对于这种情况,我们只能手动修gestureRecognizerShouldBegin:中的逻辑,来适配RTL
UISwipeGestureRecognizer有一个direction的属性,可以设置有效方向。为了适配RTL,我们可以hook它的setter方法,达到自动适配的目的:
@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下,箭头就需要镜像成朝右。系统对这种情况提供了一个镜像的方法:
// 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);
然而....这个方法并不好用。通过切换系统语言,来适配RTL应该是没问题的。但是在App内部切换语言,手动修改RTL布局,系统的这个方法就经常出现错误镜像的情况。无奈,我们只好自己写一个方法,来达到这个目的:
@implementation UIImage (HTSFlipped)
- (UIImage *)hts_imageFlippedForRightToLeftLayoutDirection
{
if (isRTL()) {
return [UIImage imageWithCGImage:self.CGImage
scale:self.scale
orientation:UIImageOrientationUpMirrored];
}
return self;
}
@end
对于需要在RTL下镜像的图片,手动对image调用hts_imageFlippedForRightToLeftLayoutDirection即可
UIEdgeInsets
UI上跟左右方向有关的还有UIEdgeInsets,特别是UIButton的imageEdgeInsets和titleEdgeInsets。正常的时候,我们设置一个titleEdgeInsets的left。但是当RTL的情况下,因为所有的东西都左右镜像了,应该设置titleEdgeInsets的right布局才会正常。然而系统却不会自动帮我们将left和right调换。我们需要手动去适配它。
为了快速适配,我们hook了UIButton的setContentEdgeInsets,setImageEdgeInsets,setTitleEdgeInsets方法在RTL情况下,手动调换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的地方,我们只对常用的入口进行hook,对某些不常见的地方,我们也提供是rtl_EdgeInsetsMake方法,用它代替UIEdgeInsetsMake,进行适配
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也是需要调整的,官方文档中默认textAlignment是NSTextAlignmentNatural,并且NSTextAlignmentNatural可用自动适配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
然而,情况并没有文档描述的那么好,当我们在系统内切换语言的时候,系统经常会错误的设置textAlignment。没有办法,我们只有自己去适配textAlignment.
以UILabel为例,我们hook它的setter的方法,根据当前是否是RTL,来设置正确的textAlignment,如果UILabel从未调用setTextAlignment:,我们还需要给它一个正确的默认值。
@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为例,对于AttributeString,UILabel的textAlignment是不生效的,因为AttributeString自带attributes。为了让attributeString也能自动适配RTL。我们需要在RTL下,将Alignment的left和right互换。
attributeString的alignment一般使用NSMutableParagraphStyle设置,所以我们首先hook NSMutableParagraphStyle,在setAlignment的时候设上正确的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不设置ParagraphStyle,或者ParagraphStyle没有调用setAlignment,hook是无效的。
适配这种情况,有2种办法:
- 一种是hook NSAttributedString的初始化方法,在里面给attributeString加上合适的alignment。
- 一种是hook UILabel的setAttributeString,在里面对attributeString做处理。
两种hook都无法处理好所有的情况:
- NSAttributedString是类族,类族是对外屏蔽真实class的,我们很难完全覆盖到所有NSAttributedString的class,更何况还有NSMutableAttributedString等子类的类族。
- 可以使用AttributeString的地方非常多,除了UILabel还有UITextView等,这里也无法处理到所有的情况
基于这种情况,由于使用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字符串
由于阅读习惯的差异(阿拉伯语从右往左阅读,其他语言从左往右阅读),所以字符的排序是不一样的,普通语言左边是第一个字符,阿拉伯语右边是第一个字符。
如果是单纯某种文字,不管是阿拉伯语还是英文,系统都是已经帮助我们做好适配了的。然而混排的情况下,系统的适配是有问题的。对于一个string,系统会用第一个字符来决定当前是LTR还是RTL。
那么坑来了,假设有一个这样的字符串@"小明بدأ في متابعتك"(翻译过来为:小明关注了你),在阿拉伯语的情况下,由于阅读顺序是从右往左,我们希望他显示为@"بدأ في متابعتك小明"。然而按照系统的适配方案,是永远无法达到我们期望的。
- 如果"小明"放前面,第一个字符是中文,系统识别为LTR,从左往右排序,显示为@"小明بدأ في متابعتك"。
- 如果"小明"放后面,第一个字符是阿拉伯语,系统识别为RTL,从右往左排序,依然显示为@"小明بدأ في متابعتك"。
为了适配这种情况,可以在字符串前面加一些不会显示的字符,强制将字符串变为LTR或者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。为了统一适配刚刚的情况,我们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,但是由于修改了原来字符串,虽然不会显示出来,但是毕竟多加了字符,会改变原来各个字符的range位置,当我们有特殊逻辑要使用各种range的时候,可能会有问题,对于这种特殊的情况,无法做到统一适配,所以只能具体情况具体处理
总结
至此,大部分的情况都可以适配了。整个适配过程,尽量使用hook的方式,统一处理,避免代码的侵入性。然而有很多地方只能处理最基本的情况,对很多特殊case是无法兼容的,比如textAlignment的处理,无法覆盖到所有View。比如Unicode字符串的处理,某些特殊case下可能会有坑。对于这些特殊case,我们再具体处理。
整体来说,虽然系统在iOS9之后就支持RTL了,但是因为是整个布局方式都改变,系统也无法做到尽善尽美,这个适配过程还是有很多坑需要去填。