[譯] Story 中 Type Mode 在 iOS 和 Android 上的實現

金西西發表於2019-02-21

Instagram 最近推出了 Type Mode,這是一種在 Story 上釋出有創意的、動態文字樣式和背景的帖子的新方式。Type Mode 對我們來說是一個有趣的挑戰,因為這是我們的一次創新:讓人們在在沒有照片或視訊輔助的情況下在 Story 上進行分享 —— 我們希望確保 Type Mode 仍然是一種有趣、可定製且具有視覺表現力的體驗。

在 iOS 和 Android 上無縫地實現 Type Mode 功能有各自相應的一系列挑戰,包括動態調整文字大小和自定義填充背景。在這篇文章中,將看到我們如何在 iOS 和 Android 平臺上完成這項工作。

[譯] Story 中 Type Mode 在 iOS 和 Android 上的實現

動態調整文字輸入的大小

在 Type Mode 下,我們想要建立一個讓人們可以強調特定的單詞或短語的文字輸入體驗。一種方法是構建兩端對齊的文字樣式,動態調整每一行的大小,以填充既定的寬度(在 Instagram 的現代、霓虹和粗體中使用)。

iOS

iOS 的主要挑戰是在原生的 UITextView 中渲染可以動態改變大小的文字,這讓使用者得以快速熟悉的方式輸入文字。

在儲存文字前調整文字大小

當你輸入一行文字的時候,文字大小應該隨著輸入而相應縮小,直到達到最小字型。

[譯] Story 中 Type Mode 在 iOS 和 Android 上的實現

為了實現這個需求,我們結合了 UITextView.typingAttributesNSAttributedStringNSLayoutManager

首先,我們需要計算我們的文字將呈現什麼樣的字型和大小。我們可以使用 [NSLayoutManager enumerateLineFragmentsForGlyphRange:usingBlock:] 來抓取當前輸入的那行文字的範圍。根據這個範圍,我們可以建立一個帶有尺寸的字串來計算最小字型大小。

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 會像通常那樣包著我們的文字。

在儲存文字後整理文字

在我們的文字被提交到文字儲存後,我們可能需要清理一些尺寸屬性。我們的文字可能已經換行,或者使用者可以通過手動新增換行符,在單獨的行上寫入更大的文字來「強調」。

[譯] Story 中 Type Mode 在 iOS 和 Android 上的實現

放置這個邏輯的好地方是 [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();
複製程式碼
[譯] Story 中 Type Mode 在 iOS 和 Android 上的實現

接下來,我們需要瀏覽佈局並分別調整每行文字的大小。沒有直接的方法可以完美地得到某行文字的大小,但是我們可以通過二進位制搜尋來輕鬆估算出最大文字大小,而不會造成強制換行:

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);
複製程式碼

現在,每行文字都會填充合適寬度!每次文字更改的時候,我們都可以重複此過程來實現動態調整文字。

[譯] Story 中 Type Mode 在 iOS 和 Android 上的實現

自定義背景

我們還希望使用 Type Mode 讓人們通過文字的背景來強調單詞和短語(用於打字機字型和粗體)。

iOS

另一種我們可以利用 NSLayoutManager 的方式是繪製自定義背景填充。NSAttributedString 雖然可以用 NSBackgroundColorAttributeName 屬性設定背景顏色,但它不可自定義,也不可擴充套件。

[譯] Story 中 Type Mode 在 iOS 和 Android 上的實現

例如,如果我們使用了 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. 背景是矩形的,無法調整圓角。
[譯] Story 中 Type Mode 在 iOS 和 Android 上的實現

為了解決這些問題,我們嘗試使用 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...
  }
}
複製程式碼
[譯] Story 中 Type Mode 在 iOS 和 Android 上的實現

結論

Instagram 擁有非常強大的原型設計文化,而設計團隊的 Type Mode 原型讓我們在每次迭代中都能感受到真實的使用者體驗。例如,對於霓虹燈樣式,我們需要一種方法從調色盤中獲取單一顏色,然後為文字生成內部顏色和發光顏色。這個專案的設計師在他的原型中使用了一些方法,當他找到一個他喜歡的東西時,我們基本上只是在 Android 和 iOS 上覆制他的邏輯。與設計團隊的這種級別的合作是此次推出的一個特殊部分,並使開發流程非常高效。

如果你有興趣與我們在 Story 中合作,請檢視我們的職業頁面,瞭解位於 Menlo Park,紐約和舊金山的職位。

Christopher Wendel 和 Patrick Theisen 分別是 Instagram 的 iOS 和 Android 工程師。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章