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:

RTL适配历程_第1张图片
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了,但是因为是整个布局方式都改变,系统也无法做到尽善尽美,这个适配过程还是有很多坑需要去填。

你可能感兴趣的:(RTL适配历程)