RTL适配历程

作者: 小笨狼 | 来源:发表于2018-04-03 14:11 被阅读635次

    背景

    阿拉伯语适配是一个比较麻烦的事情,不止在于它文案的适配,更多的是在于其语言习惯的变化。由从左到右(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了,但是因为是整个布局方式都改变,系统也无法做到尽善尽美,这个适配过程还是有很多坑需要去填。

    相关文章

      网友评论

      • 灰_太_狼:大佬,问一个问题。App里面有切换语言的功能。我是这么实现的。当用户切换语言,结束所有控制器,切换跟控制器,然后根据你博客上说的
        if ([Helper isEnglish]) {
        [UIView appearance].semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
        }else {
        [UIView appearance].semanticContentAttribute = UISemanticContentAttributeForceRightToLeft;
        }
        调用了强制切换布局的方法。做完后UI啥的都一切正常。但是发现cpu使用率飙涨,简单页面上还好,复杂页面上,cpu会彪到100%以上。体现在UI上就是一点什么按钮就会延迟个1-2s,cpu瞬彪然后降回去。我杀死进程后在进入就一切正常。另外切换语言如果之前是UISemanticContentAttributeForceLeftToRight切换后还是UISemanticContentAttributeForceLeftToRight就正常,如果之前UISemanticContentAttributeForceLeftToRight切换后为UISemanticContentAttributeForceRightToLeft就必须这个bug。求教
      • 灰_太_狼:大佬,请问富文本图文混排那种。NSMutableAttributedString怎么适配RTL 我试过改frame 啥的不管用
        小笨狼:@羽化归来 用NSParagraphStyle设置左右
      • akzhang6666:App内设置切换RTL,怎么全局切换呢
        akzhang6666:@小笨狼 老哥,有那些分类代码么
        akzhang6666:@小笨狼 谢谢~
        小笨狼:重建window
      • wg689:Jack在滴滴还是头条呢
      • 苛求帅:做法差不多,一样踩过这个坑,只是我没有用leading,二是改写了部分Masonry
      • 曾宪华:嗨!是否考虑来阿里巴巴看看啊?

        Jack
        小笨狼:抱歉,暂时不考虑哈
      • 三十一_iOS:之前做过一个关于阿拉伯的项目,现在想想真的是痛不欲生。

      本文标题:RTL适配历程

      本文链接:https://www.haomeiwen.com/subject/mrnqhftx.html