关于ios:项目RTL语言适配实践中遇到的问题和总结

2次阅读

共计 9554 个字符,预计需要花费 24 分钟才能阅读完成。

图片来自:https://picography.co/ocean-s…
本文作者:JDMin

开篇

当今大概有超过 22 个国家,6.6 亿人应用阿拉伯文字,使其成为仅次于拉丁文和中文的世界第三大书面语言。随着业务在海内扩大的逐步深刻,App 适配阿拉伯语曾经提上了日程。与咱们平时接触较多的中英文区别最显著的是,阿拉伯语的书写和应用习惯是从右到左的。只管 iOS 自身曾经有很多对于这种 RTL(Right-To-Left) 语言的解决,然而在咱们开发的时候,须要留神应用正确的标准去防止谬误。同时每个业务和 App 都有各自的一些非凡设计和其余特点,这些特点也会带来许多新的问题待解决。上面介绍下最近工程在适配 RTL 语言中遇到的问题和解决。

在介绍具体的各个问题场景前,咱们先对 RTL 语言与工程适配相干的一些次要特点做下介绍:

  • 文本。与中文、英文等 LTR(Left-To-Right) 语言最显著的不同是,RTL 语言在书写和浏览习惯上是从右到左的。
  • 图标。图标要针对每个具体图标灵活处理。思考到 RTL 语言的文案和应用习惯是从右到左,所以很多有明确方向性的图标须要扭转下方向(举个例子来说,比方罕用的箭头图标)。至于其余常规性图标,则在 UI 中放弃不变。
  • 数字。咱们日常接触较多是阿拉伯数字,或者称作西阿拉伯数字。与之绝对的,是东阿拉伯数字。不同的阿拉伯国家应用不同的阿拉伯数字,比方摩洛哥、阿尔及利亚罕用西阿拉伯数字,而伊朗、阿富汗、巴基斯坦等国家则应用东阿拉伯数字。而像埃及、沙特阿拉伯等国家,则两种模式的阿拉伯数字都会应用。开发前须要确认分明咱们须要提供服务的地区应用的是哪种阿拉伯数字,并且正确的解决和展现。

工程现状和特点

实际上,iOS 零碎曾经对 RTL 语言做了诸多解决,并且提供了许多 API 不便下层做业务适配。不过在探讨这些具体的问题之前,咱们须要先理解以后工程的现状和特点,并依此来抉择最合适的解决计划。总结来说,工程以后的几个特点:

  1. 体量较大。工程倒退到明天,代码量曾经比拟宏大。对于比拟大的改变须要思考革新老本,以及是不是会对未来业务扩大落下什么隐患。
  2. 工程有大量的布局代码,特地是比拟晚期的业务的布局代码,是应用 frame layout 手动布局的形式解决,没有应用 AutoLayout。
  3. App 反对用户利用内设置语言。利用首次启动会抉择用户的零碎语言作为默认语言,同时反对用户在利用内切换语言。

至于布局形式、利用内设置语言对 RTL 适配的具体影响,咱们在后文具体介绍。

遇到的问题

利用内切换语言

当咱们在零碎设置中将语言设置为阿拉伯语等 RTL 语言后,零碎会主动将 App 的布局形式改为 RTL 布局。这里就会遇到第一个问题,咱们 App 内能够设置语言,当利用设置语言和零碎语言的布局形式不统一时 (比方利用内设置成阿拉伯语,零碎设置成英语),咱们心愿以利用内语言为准。这个时候,就无奈再应用零碎的默认解决。在 iOS9 当前,iOS 为UIView 凋谢了一个新的property

@property (nonatomic) UISemanticContentAttribute semanticContentAttribute API_AVAILABLE(ios(9.0));

通过 semanticContentAttribute 能够在由开发者自定义一个 view 在 RTL 和 RTL 布局下是否做翻转解决。咱们须要依据利用内语言设置 App 里 View 的 semanticContentAttribute ,防止应用零碎的默认判断。这里对一个个 View 做改变显然过于麻烦,
咱们的做法是在语言设置的时候,通过设置 UIView.appearance().semanticContentAttribute 依据对全局做解决。

if isRTLLanguage {UIView.appearance().semanticContentAttribute = .forceRightToLeft
} else {UIView.appearance().semanticContentAttribute = .forceLeftToRight
}

对于有非凡适配场景的 View (在 RTL 模式下也不翻转),能够在业务顶层自行设置相干 UI 元素实例的semanticContentAttribute

布局

当初咱们罕用的布局形式个别有 2 种。一种是应用 AutoLayout,一种是 frame layout 的手动布局。咱们一一介绍。

对于 AutoLayout,包含罕用的三方封装库 Masonry、SnapKit 等,对 RTL 都曾经有比拟好的兼容解决。在 RTL 和 LTR 中,Left 和 Right 对应的理论方向雷同,布局不会有变动。因而,咱们在设置束缚的时候,须要应用具备通用意义的 Leading 和 Trailing 来替换以往罕用的 Left 和 Right。Leading 为前部束缚,对应 LTR 中的 Left 和 RTL 中的 Right。Trailing 为尾部束缚,对应 LTR 中的 right 和 RTL 中的 Left。应用 Leading 和 Trailing 设置束缚,View 会依据本身的 semanticContentAttribute 具体是 LTR 或是 RTL 主动调整布局。

因为咱们业务内以后有大量的布局代码是应用 frame layout 手动布局,全副切换到 AutoLayout 不事实。特地是对于有简单 UI 元素和布局逻辑的场景,重写布局艰难而且相当耗时。因而须要思考给这种布局形式提供更小改变老本的 RTL 适配形式。当咱们在 LTR 中设置left(view.origin.x) = a,映射到 RTL 坐标系,其实就是设置right(view.left + view.width) = view.superview.width - a。当咱们在 LTR 中设置right = a,映射到 RTL 坐标系,其实就是设置view.superview.width - a + self.width,因而,咱们能够将 2 个坐标系统一化,参照 AutoLayout 中的定义,扩大 View 的 leading 和 trailing 属性。

@implementation UIView (RTL)

- (CGFloat)leading {NSAssert(self.superview != nil, @"应用 leading 必须以后 view 增加到 superView!");
    if ([self isRTL]) {return self.superview.width - self.right;}
    return self.left;
}

- (void)setLeading:(CGFloat)leading {NSAssert(self.superview != nil, @"应用 leading 必须以后 view 增加到 superView!");
    if ([self isRTL]) {self.right = self.superview.width - leading;} else {self.left = leading;}
}

- (CGFloat)trailing {NSAssert(self.superview != nil, @"应用 trailing 必须以后 view 增加到 superView!");
    if ([self isRTL]) {return self.leading + self.width;}
    return self.right;
}

- (void)setTrailing:(CGFloat)trailing {NSAssert(self.superview != nil, @"应用 trailing 必须以后 view 增加到 superView!");
    if ([self isRTL]) {self.right = self.superview.width - trailing + self.width;} else {self.left = trailing - self.width;}
}

@end

在设置 leading 和 trailing 前,要求 View 曾经增加到 superview,并且 size 曾经设置。这个在绝大多数场景下能够满足 (以咱们工程为例,临时没有遇到无奈满足条件的场景)。
新增了这几个相干办法后,在 RTL 适配时,对于本来 (LTR 场景) 的 left 设置,改成应用 leading 设置。本来的 right 设置,改成应用 trailing 设置。和 AutoLayout 的概念用法基本相同,适配老本大幅减小。

Image

就像在上文说过,并不是所有图片都须要在 RTL 模式下翻转,只有一部分图片 (一般来说,经常是有比拟明确方向含意和性质图片) 须要翻转。
对于须要翻转的图片,有几种形式能够解决。
在 iOS9 之后,UIImage 新增了相干办法,

- (UIImage *)imageFlippedForRightToLeftLayoutDirection API_AVAILABLE(ios(9.0));
@property (nonatomic, readonly) BOOL flipsForRightToLeftLayoutDirection API_AVAILABLE(ios(9.0));

对于须要在 LTR 和 RTL 下不同翻转的 image,能够通过 imageView.image = targetImage.imageFlippedForRightToLeftLayoutDirection() 来设置。或者在 Image Set 中,设置相干图片资源的 Direction,

须要留神的是,这两种办法是作用于 UIImageView 上,对于其余容器会有效。同时要留神展现时是应用 UIImageView semanticContentAttribute 做翻转判断,semanticContentAttribute 设置谬误的话最终展现图片也会谬误。
鉴于以上起因,能够在对 UIImage 提供自定义的翻转办法,

@implementation UIImage (RTL)
- (UIImage *_Nonnull)checkOverturn {if (isRTL) {UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale);
        CGContextRef bitmap = UIGraphicsGetCurrentContext();
        CGContextTranslateCTM(bitmap, self.size.width / 2, self.size.height / 2);
        CGContextScaleCTM(bitmap, -1.0, -1.0);
        CGContextTranslateCTM(bitmap, -self.size.width / 2, -self.size.height / 2);
        CGContextDrawImage(bitmap, CGRectMake(0, 0, self.size.width, self.size.height), self.CGImage);
        UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
        return image;
    }
    return self;
}
@end

同时提供对 View 容器的翻转办法,

@implementation UIView (RTL)
- (void)checkOverturn {
    // 防止反复翻转
    if (self.overturned) {return;}
    // 基于 transform 翻转
    self.transform = CGAffineTransformScale(self.transform, -1, 1);
}
@end

顶层业务能够依据理论场景,抉择适合的办法解决。

文本

文本这里须要解决的比拟重要的问题有 3 个,一个是 text 的对齐形式(alignment)。一个是 AttributeString 的解决。一个是 text 里字符的排列程序(字符从左往右或者从右往左)。咱们一一介绍。

alignment

咱们先探讨 alignment。在 NSText 中,NSTextAlignment定义为,

/* Values for NSTextAlignment */
typedef NS_ENUM(NSInteger, NSTextAlignment) {
    NSTextAlignmentLeft      = 0,    // Visually left aligned
#if TARGET_ABI_USES_IOS_VALUES
    NSTextAlignmentCenter    = 1,    // Visually centered
    NSTextAlignmentRight     = 2,    // Visually right aligned
#else /* !TARGET_ABI_USES_IOS_VALUES */
    NSTextAlignmentRight     = 1,    // Visually right aligned
    NSTextAlignmentCenter    = 2,    // Visually centered
#endif
    NSTextAlignmentJustified = 3,    // Fully-justified. The last line in a paragraph is natural-aligned.
    NSTextAlignmentNatural   = 4     // Indicates the default alignment for script
} 

咱们以最罕用的 Text 容器 UILabel 为例。对于 UILabel ,如果没有设置textAlignment ,在 iOS9 之前会默认是NSTextAlignmentLeft ,在 iOS9 之后默认是NSTextAlignmentNatural NSTextAlignmentNatural 会依据零碎语言是否是 RTL,主动帮咱们调整适合的 alignment。对于须要利用内设置语言的场景,因为利用内语言可能和零碎语言不统一,没法应用零碎的默认解决。须要依据以后利用内是否设置是 RTL 语言,手动设置 UILabel textAlignment 。出于便捷性思考,能够扩大 UILabelrtlAlignment办法。业务层依据须要设置rtlAlignment

typedef NS_ENUM(NSUInteger, NMLLabelRTLAlignment) {
    NMLLabelRTLAlignmentUndefine,
    NMLLabelRTLAlignmentLeft,
    NMLLabelRTLAlignmentRight,
    NMLLabelRTLAlignmentCenter,
};

@implementation UILabel (RTL)

- (void)setRtlAlignment:(RTLAlignment)rtlAlignment {[self bk_associateValue:@(rtlAlignment) withKey:@selector(rtlAlignment)];
    
    switch (rtlAlignment) {
        case RTLAlignmentLeading:
            self.textAlignment = (isRTL ? NSTextAlignmentRight : NSTextAlignmentLeft);
            break;
        case RTLAlignmentTrailing:
            self.textAlignment = (isRTL ? NSTextAlignmentLeft : NSTextAlignmentRight);
            break;
        case RTLAlignmentCenter:
            self.textAlignment = NSTextAlignmentCenter;
        case RTLAlignmentUndefine:
            break;
        default:
            break;
    }
}

- (RTLAlignment)rtlAlignment {NSNumber *identifier = [self bk_associatedValueForKey:@selector(rtlAlignment)];
    if (identifier) {return identifier.integerValue;}
    return RTLAlignmentUndefine;
}

@end

AttributeString 的解决

因为设置 textAlignment 无奈对 AttributeString 失效,所以 AttributeString 须要独自解决。解决形式和设置 textAlignment 相似,只是换成应用 NSParagraphStyle 来解决。

@implementation NSMutableAttributedString (RTL)

- (void)setRtlAlignment:(RTLAlignment)rtlAlignment {switch (rtlAlignment) {
        case RTLAlignmentLeading:
            self.yy_alignment = (isRTL ? NSTextAlignmentRight : NSTextAlignmentLeft);
            break;
        case RTLAlignmentTrailing:
            self.yy_alignment = (isRTL ? NSTextAlignmentLeft : NSTextAlignmentRight);
            break;
        case RTLAlignmentCenter:
            self.yy_alignment = NSTextAlignmentCenter;
        case RTLAlignmentUndefine:
            break;
        default:
            break;
    }
}

@end

字符排列程序

零碎会应用 Text 的第一个字符作为排列程序的判断根据。比方文本 ”مرحبا 你好 ”,因为第一个字符是阿拉伯语字符,所以零碎会应用 RTL 规定解决。同理,如果文本是 ” 你好 مرحبا”,因为第一个字符是中文,则会应用 LTR 规定。这个解决形式在 Text 中只有繁多语言时没有问题,不过遇到 RTL 语言和 LTR 语言混合的场景,状况就会变得复杂许多,须要有更粗疏的思考。

以一个常见的例子阐明,比方聊天音讯中常常应用的 @格局语法,在 LTR 和 RTL 中大略有这些场景,

能够看到,尽管零碎的这个默认解决能够应答少数的状况。然而在一些场景下无奈满足需要,比方下面的 label[3]。
咱们心愿将 ”@” 与前面的用户名称视为一个整体,对于 “مرحبا@我, 今天天气好吗 ”,咱们预期展现成 “@我,今天天气好吗مرحبا”,然而最终展现成了 ” 我, 今天天气好吗 @مرحبا”。或者比方咱们心愿是以 LTR 展现,

然而最终会展现成,

对于这些场景,咱们须要插入一些相干的 Unicode 来做纠正。比拟罕用的相干的 Unicode 有以下这些。

再回到方才 2 个例子,对于 ”مرحبا@我, 今天天气好吗 ”,iOS 将 @也当成了阿拉伯语مرحبا的一部分,咱们须要对 @手动增加 LEFT-TO-RIGHT 标记 \u200E,申明为 LTR 展现。对于第 2 个例子,咱们须要对几个阿拉伯文增加 \u202A 申明为 LTR 展现,同时应用 \u202C 作为完结标签。

其余留神点

除了以上介绍的这些,还有一些比拟零散的点须要留神。

UICollectionView

UICollectionView 在 RTL 场景下也须要翻转,零碎不会帮咱们默认做这个事件,须要咱们自行处理。在 iOS11 之后,UICollectionViewLayout扩大了一个 readonly property

@property(nonatomic, readonly) BOOL flipsHorizontallyInOppositeLayoutDirection; 

flipsHorizontallyInOppositeLayoutDirection默认为 false,当设置为true 时,UICollectionView 会依据以后 RTL 状况,翻转程度坐标系。因为这是一个 readonly 的属性,咱们须要继承 UICollectionViewLayout 并改写 flipsHorizontallyInOppositeLayoutDirection 的 getter 办法。

UIEdgeInsets

UIEdgeInsets 中定义的是 leftright,在 RTL 场景下,零碎不会帮咱们做翻转解决。只管在 iOS11 当前,零碎新增了 NSDirectionalEdgeInsets 定义,然而对罕用的 UI 控件 (比方UIButton 等)并没有扩大相干属性,还是须要设置 UIEdgeInsets。因而能够思考新增相似UIEdgeInsetsMake_RTLFlip 的定义,不便下层应用。

UIEdgeInsets UIEdgeInsetsMake_RTLFlip(CGFloat top, CGFloat left, CGFloat bottom, CGFloat right)
{if (!isRTL)
    {UIEdgeInsets insets = {top, left, bottom, right};
        return insets;
    }
    UIEdgeInsets insets = {top, right, bottom, left};
    return insets;
}

UINavigationController

navigationBar 的滑动返回手势,会依据以后零碎语言做 RTL 解决。对于咱们罕用的 LTR 场景,是右滑返回。在 RTL 场景下是左滑返回。对于利用内自定义语言的场景,设置 UIView.appearance().semanticContentAttribute 不会扭转这个手势,还须要设置UINavigationController.view.semanticContentAttribute

- (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {if (self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]) {self.view.semanticContentAttribute = [UIView appearance].semanticContentAttribute;
    }
    return self;
}

Gesture

对于带方向性的 Gesture(比方 UISwipeGestureRecognizer 等),零碎不会对手势的响应方向做扭转。这个只能下层依据以后是否是 RTL 场景做逻辑判断。一般来说,这类手势并不会十分频繁的应用,因而业务层适配解决老本不大。

数字

如同开篇时介绍的那样,数字同样是须要思考的一个重要的点。到底是应用东方阿拉伯数字,还是西方阿拉伯数字,在数字规定和展现上都有差别。因为这次业务适配应用的东方阿拉伯数字规定,和咱们日常接触的雷同,这里就不再开展。如果是应用东阿拉伯数字,那数字逻辑就要额定解决。

总结

到这里,总体的 RTL 兼容根本实现。总结来说,因为以后 App 须要反对利用内设置语言,导致不少问题变得复杂化。而且因为 App 自身的诸多特点,在方案设计的时候须要抉择改变老本和危险都绝对可控的计划来解决。对于不须要利用内独立设置 App 语言,或者是刚要从 0 到 1 开发 App 的话,能够依据本身的业务特点,设计更适合以后业务的计划。

参考资料

  • Internationalization and Localization Guide
  • Design for Arabic
  • How to use Unicode controls for bidi text

本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术岗位,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!

正文完
 0