[譯] 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索