[译] Story 中 Type Mode 在 iOS 和 Android 上的实现

Instagram 最近推出了 Type Mode,这是一种在 Story 上发布有创意的、动态文本样式和背景的帖子的新方式。Type Mode 对咱们来讲是一个有趣的挑战,由于这是咱们的一次创新:让人们在在没有照片或视频辅助的状况下在 Story 上进行分享 —— 咱们但愿确保 Type Mode 仍然是一种有趣、可定制且具备视觉表现力的体验。前端

在 iOS 和 Android 上无缝地实现 Type Mode 功能有各自相应的一系列挑战,包括动态调整文本大小和自定义填充背景。在这篇文章中,将看到咱们如何在 iOS 和 Android 平台上完成这项工做。java

动态调整文本输入的大小

在 Type Mode 下,咱们想要建立一个让人们能够强调特定的单词或短语的文本输入体验。一种方法是构建两端对齐的文本样式,动态调整每一行的大小,以填充既定的宽度(在 Instagram 的现代、霓虹和粗体中使用)。android

iOSios

iOS 的主要挑战是在原生的 UITextView 中渲染能够动态改变大小的文本,这让用户得以快速熟悉的方式输入文本。git

在存储文本前调整文字大小github

当你输入一行文本的时候,文字大小应该随着输入而相应缩小,直到达到最小字体。canvas

为了实现这个需求,咱们结合了 UITextView.typingAttributesNSAttributedStringNSLayoutManager后端

首先,咱们须要计算咱们的文本将呈现什么样的字体和大小。咱们可使用 [NSLayoutManager enumerateLineFragmentsForGlyphRange:usingBlock:] 来抓取当前输入的那行文字的范围。根据这个范围,咱们能够建立一个带有尺寸的字符串来计算最小字体大小。bash

CGFloat pointSize = 24.0; // 随意
NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:string attributes:@{NSFontAttributeName:[UIFont fontWithName:fontName size:pointSize]}];
CGFloat textWidth = CGRectGetWidth([attributedString boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NULL context:nil]);
CGFloat scaleFactor = (textViewContainerWidth / textWidth);
CGFloat preferredFontSize = (pointSize * scaleFactor);
return CLAMP_MIN_MAX(preferredFontSize, minimumFontSize, maximumFontSize) // 将字体固定住,在最大值最小值之间
复制代码

为了能以正确的大小绘制文本,咱们须要在 UITextViewtypingAttributes 中使用咱们新的字体大小。UITextView.typingAttributes 是用于设置用户正在输入的文本的属性。在 [id <UITextViewDelegate> textView:shouldChangeTextInRange:replacementText:] 方法中实现比较合适。框架

- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
    NSMutableDictionary *typingAttributes = [textView.typingAttributes mutableCopy];
    typingAttributes[NSFontAttributeName] = [UIFont fontWithDescriptor:fontDescriptor size:calculatedFontSize];
    textView.typingAttributes = typingAttributes;
    return YES;
}
复制代码

这意味着,随着用户输入,字体大小将缩小,直到达到某个指定的最小值。这时 UITextView 会像一般那样包着咱们的文本。

在存储文本后整理文字

在咱们的文本被提交到文本存储后,咱们可能须要清理一些尺寸属性。咱们的文本可能已经换行,或者用户能够经过手动添加换行符,在单独的行上写入更大的文字来「强调」。

放置这个逻辑的好地方是 [id <UITextViewDelegate> textViewDidChange:] 方法。这发生在文本被提交到文本存储,而且最初由文本引擎排版以后。

要得到每行的字符范围列表,咱们可使用 NSLayoutManager

NSMutableArray<NSValue *> *lineRanges = [NSMutableArray array];
[textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, layoutManager.numberOfGlyphs) usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer * _Nonnull textContainer, NSRange glyphRange, BOOL * _Nonnull stop) {
    NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];
    [lineRanges addObject:[NSValue valueWithRange:characterRange]];
}];
复制代码

而后,咱们须要经过在每行具备正确字体大小的范围上设置属性来操做 NSTextStorage

编辑 NSTextStorage 有三个步骤,它自己就是 NSMutableAttributedString 的子类。

  1. 调用 [textStorage beginEditing] 来表示咱们正在对文本存储进行一次或屡次更改。
  2. 发送一些编辑信息到 NSTextStorage。在咱们的例子中,NSFontAttributeName 属性应该设置为对应行的正确字体大小。咱们可使用相似的方法来计算字体大小,就像咱们以前作的那样。
for (NSValue *lineRangeValue in lineRanges) {
    NSRange lineRange = lineRangeValue.rangeValue;
    const CGFloat fontSize = ... // 与上文相同的字体大小计算方法
    [textStorage setAttributes:@{NSFontAttributeName : [UIFont fontWithDescriptor:fontDescriptor size:fontSize]} range:lineRange];
}
复制代码
  1. 调用 [textStorage endEditing] 来表示咱们结束编辑文本存储。这会调用 [NSTextStorage processEditing] 方法,该方法将修复咱们改变的范围内文本的属性。这也会调用正确的 NSTextStorageDelegate 方法。

TextKit 是一个功能强大且现代化的 API,与 UIKit 紧密集成。许多文字体验均可以用它来设计,而且几乎每次 iOS 的新版本都会发布一些和文本相关的 API。使用 TextKit 你能够作任何事情,从建立自定义文本容器到修改实际生成的字形。并且因为它是创建在 CoreText 之上的,而且与 UITextView 等 API 集成,因此文本输入和编辑仍然感受像原生 iOS 体验。

Android

Android 没有开箱即用的两端对齐的方法,但框架的 API 为咱们提供了本身实现所需的所有工具。

第一步是将文本用最小文本大小布局出来。稍后咱们会扩展它,可是这会告诉咱们有多少行和断行的位置:

TextPaint textPaint = new TextPaint();
textPaint.setTextSize(SIZE_MIN);
Layout layout =
    new StaticLayout(
        text,
        textPaint,
        availableWidth,
        Layout.Alignment.ALIGN_CENTER,
        1 /* spacingMult */,
        0 /* spacingAdd */,
        true /*includePad */);
int lineCount = layout.getLineCount();
复制代码

接下来,咱们须要浏览布局并分别调整每行文字的大小。没有直接的方法能够完美地获得某行文字的大小,可是咱们能够经过二进制搜索来轻松估算出最大文字大小,而不会形成强制换行:

int lowSize = SIZE_MIN;
int highSize = SIZE_MAX;
int currentSize = lowSize + (int) Math.floor((highSize - lowSize) / 2f);
while (low < current) {
  if (hasLineBreak(text, currentSize)) {
    highSize = currentSize;
  } else {
    lowSize = currentSize;
  }
  currentSize = lowSize + (int) Math.floor((highSize - lowSize) / 2f);
}
复制代码

一旦咱们为每行文字找到合适的尺寸,能够将它应用到一个 span 上。span 容许咱们为每行文字使用不一样的文本大小,而不是整个字符串只有单一文本大小:

text.setSpan(
    new AbsoluteSizeSpan(textSize),
    layout.getLineStart(lineNumber),
    layout.getLineEnd(lineNumber),
    Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
复制代码

如今,每行文本都会填充合适宽度!每次文本更改的时候,咱们均可以重复此过程来实现动态调整文本。

自定义背景

咱们还但愿使用 Type Mode 让人们经过文字的背景来强调单词和短语(用于打字机字体和粗体)。

iOS

另外一种咱们能够利用 NSLayoutManager 的方式是绘制自定义背景填充。NSAttributedString 虽然能够用 NSBackgroundColorAttributeName 属性设置背景颜色,但它不可自定义,也不可扩展。

例如,若是咱们使用了 NSBackgroundColorAttributeName,整个文本视图的背景将被填充。咱们不能排除行内空格、不能在行间留出空隙或者让填充的背景是圆角。谢天谢地,NSLayoutManager 给了咱们重写绘制背景填充的方法。咱们须要建立一个 NSLayoutManager 子类并重写 drawBackgroundForGlyphRange:atPoint:

@interface IGSomeCustomLayoutManager : NSLayoutManager
@end 
@implementation IGSomeCustomLayoutManager
- (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin {
    // Draw custom background fill
    [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin];
}
    
}];
@end
复制代码

经过 drawBackgroundForGlyphRange:atPoint 方法,咱们能够再次利用 [NSLayoutManager enumerateLineFragmentsForGlyphRange:usingBlock] 来获取每一行片断的字形范围。而后使用 [NSLayoutManager boundingRectForGlyphRange:inTextContainer] 来得到每一行的边界矩形。

- (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin {
  [self enumerateLineFragmentsForGlyphRange:NSMakeRange(0, self.numberOfGlyphs) usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer * _Nonnull textContainer, NSRange glyphRange, BOOL * _Nonnull stop) {
       CGRect lineBoundingRect = [self boundingRectForGlyphRange:glyphRange inTextContainer:textContainer];
       CGRect adjustedLineRect = CGRectOffset(lineBoundingRect, origin.x + kSomePadding, origin.y + kSomePadding);
       UIBezierPath *fillColorPath = [UIBezierPath bezierPathWithRoundedRect:adjustedLineRect cornerRadius:kSomeCornerRadius];
       [[UIColor redColor] setFill];
       [fillColorPath fill];
  }];
}
复制代码

这使得咱们能够用指定的形状和间距给任意文本绘制背景填充。NSLayoutManager 也能够用来绘制其余文本属性,如删除线和下划线。

Android

乍看之下,感受这在 Android 上应该很容易实现。咱们能够添加一个 span 来修改文本背景颜色:

new CharacterStyle() {
  @Override
  public void updateDrawState(TextPaint textPaint) {
    textPaint.bgColor = color;
  }
}
复制代码

这是一个很好的首次尝试(也是咱们第一个构建的代码),但它有一些限制:

  1. 背景牢牢包裹着文字,没法调整间距。
  2. 背景是矩形的,没法调整圆角。

为了解决这些问题,咱们尝试使用 LineBackgroundSpan。咱们已经使用它来给经典字体渲染圆形的气泡背景,因此它天然也应该适用于新的文本样式。不幸的是,咱们的新用例在 Layout 框架类中发现了一个微妙的 bug。若是你的文本在不一样的行上有多个 LineBackgroundSpan 实例,那么 Layout 不会正确地遍历它们,其中一些可能永远不会被渲染。

庆幸的是,咱们能够经过对整个字符串应用单个 LineBackgroundSpan 来避免框架错误,而后咱们本身依次绘制到每个背景 span 上:

class BackgroundCoordinator implements LineBackgroundSpan {
  @Override
  public void drawBackground( Canvas canvas, Paint paint, int left, int right, int top, int baseline, int bottom, CharSequence text, int start, int end, int currentLine) {
    Spanned spanned = (Spanned) text;
    for (BackgroundSpan span : spanned.getSpans(start, end, BackgroundSpan.class)) {
      span.draw(canvas, spanned);
    }
  }
}

class BackgroundSpan {
  public void draw(Canvas canvas, Spanned spanned) {
    // Custom background rendering...
  }
}
复制代码

结论

Instagram 拥有很是强大的原型设计文化,而设计团队的 Type Mode 原型让咱们在每次迭代中都能感觉到真实的用户体验。例如,对于霓虹灯样式,咱们须要一种方法从调色板中获取单一颜色,而后为文本生成内部颜色和发光颜色。这个项目的设计师在他的原型中使用了一些方法,当他找到一个他喜欢的东西时,咱们基本上只是在 Android 和 iOS 上复制他的逻辑。与设计团队的这种级别的合做是这次推出的一个特殊部分,并使开发流程很是高效。

若是你有兴趣与咱们在 Story 中合做,请查看咱们的职业页面,了解位于 Menlo Park,纽约和旧金山的职位。

Christopher Wendel 和 Patrick Theisen 分别是 Instagram 的 iOS 和 Android 工程师。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索