Skip to content

Commit

Permalink
Add Dynamic Type support for iOS (Paper and Fabric) (#35017)
Browse files Browse the repository at this point in the history
Summary:
This adds Dynamic Type support in iOS as described [here](react-native-community/discussions-and-proposals#519).

`Text` elements have a new prop, `dynamicTypeRamp`, that allows users to specify which font ramp a particular `Text` element should take on as the OS's accessibility setting for text size. The different types line up with different values of `UIFontTextStyle`. If not specified, we default to the current behavior.

~~For the moment, this change is only for Paper. I tried applying a corresponding change to Fabric by adding an additional field to [`facebook::react::TextAttributes`](https://github.com/facebook/react-native/blob/main/ReactCommon/react/renderer/attributedstring/TextAttributes.h) and changing [`RCTEffectiveFontSizeMultiplierFromTextAttributes`](https://github.com/facebook/react-native/blob/afb124dcf0cdf0db525acc7cfd2cea2742c64068/ReactCommon/react/renderer/textlayoutmanager/platform/ios/RCTAttributedTextUtils.mm#L79-L84) to use that new field, but in the process I discovered that this function doesn't seem to ever get called, hence [this bug](#34990

## Changelog

[iOS] [Added] - Dynamic Type support

Pull Request resolved: #35017

Test Plan:
Validated with a test page in RNTester. Screenshots follow:

A) Default text size
B) Largest non-accessibility text size
C) Largest accessibility text size, split across two screenshots due to size

| A | B | C |
|-|-|-|
| ![Simulator Screen Shot - iPad (9th generation) - 2022-10-18 at 16 17 08](https://user-images.githubusercontent.com/717674/196562746-c8bbf53d-3c70-4e55-8600-0cfed8aacf5d.png) | ![Simulator Screen Shot - iPad (9th generation) - 2022-10-18 at 16 17 55](https://user-images.githubusercontent.com/717674/196563051-68bb0e34-c573-47ed-8c19-58ae45a7ce2b.png) | ![Simulator Screen Shot - iPad (9th generation) - 2022-10-18 at 16 18 33](https://user-images.githubusercontent.com/717674/196563185-61ede5ee-e79e-4af5-84a7-8f1e230a25f8.png) |
||| ![Simulator Screen Shot - iPad (9th generation) - 2022-10-18 at 16 18 42](https://user-images.githubusercontent.com/717674/196563208-2242efa2-5f24-466d-80f5-eb57a7678a67.png) |

Reviewed By: sammy-SC

Differential Revision: D40779346

Pulled By: NickGerleman

fbshipit-source-id: efc7a8e9810a93afc82c5def97af15a2e8453d90
  • Loading branch information
Adam Gleitman authored and facebook-github-bot committed Nov 16, 2022
1 parent 5fda72f commit 11c8bf3
Show file tree
Hide file tree
Showing 18 changed files with 405 additions and 1,011 deletions.
1 change: 1 addition & 0 deletions BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ REACT_PUBLIC_HEADERS = {
"React/RCTDevLoadingViewProtocol.h": RCTDEVSUPPORT_PATH + "RCTDevLoadingViewProtocol.h",
"React/RCTDevLoadingViewSetEnabled.h": RCTDEVSUPPORT_PATH + "RCTDevLoadingViewSetEnabled.h",
"React/RCTDisplayLink.h": RCTBASE_PATH + "RCTDisplayLink.h",
"React/RCTDynamicTypeRamp.h": RCTLIB_PATH + "Text/Text/RCTDynamicTypeRamp.h",
"React/RCTErrorCustomizer.h": RCTBASE_PATH + "RCTErrorCustomizer.h",
"React/RCTErrorInfo.h": RCTBASE_PATH + "RCTErrorInfo.h",
# NOTE: RCTEventDispatcher.h is exported from CoreModules:CoreModulesApple
Expand Down
1 change: 1 addition & 0 deletions Libraries/Text/BaseText/RCTBaseTextViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ - (RCTShadowView *)shadowView
RCT_REMAP_SHADOW_PROPERTY(fontStyle, textAttributes.fontStyle, NSString)
RCT_REMAP_SHADOW_PROPERTY(fontVariant, textAttributes.fontVariant, NSArray)
RCT_REMAP_SHADOW_PROPERTY(allowFontScaling, textAttributes.allowFontScaling, BOOL)
RCT_REMAP_SHADOW_PROPERTY(dynamicTypeRamp, textAttributes.dynamicTypeRamp, RCTDynamicTypeRamp)
RCT_REMAP_SHADOW_PROPERTY(maxFontSizeMultiplier, textAttributes.maxFontSizeMultiplier, CGFloat)
RCT_REMAP_SHADOW_PROPERTY(letterSpacing, textAttributes.letterSpacing, CGFloat)
// Paragraph Styles
Expand Down
2 changes: 2 additions & 0 deletions Libraries/Text/RCTTextAttributes.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#import <UIKit/UIKit.h>

#import <React/RCTDynamicTypeRamp.h>
#import <React/RCTTextDecorationLineType.h>

#import "RCTTextTransform.h"
Expand Down Expand Up @@ -36,6 +37,7 @@ extern NSString *const RCTTextAttributesTagAttributeName;
@property (nonatomic, copy, nullable) NSString *fontStyle;
@property (nonatomic, copy, nullable) NSArray<NSString *> *fontVariant;
@property (nonatomic, assign) BOOL allowFontScaling;
@property (nonatomic, assign) RCTDynamicTypeRamp dynamicTypeRamp;
@property (nonatomic, assign) CGFloat letterSpacing;
// Paragraph Styles
@property (nonatomic, assign) CGFloat lineHeight;
Expand Down
10 changes: 9 additions & 1 deletion Libraries/Text/RCTTextAttributes.m
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ - (void)applyTextAttributes:(RCTTextAttributes *)textAttributes
_fontStyle = textAttributes->_fontStyle ?: _fontStyle;
_fontVariant = textAttributes->_fontVariant ?: _fontVariant;
_allowFontScaling = textAttributes->_allowFontScaling || _allowFontScaling; // *
_dynamicTypeRamp = textAttributes->_dynamicTypeRamp != RCTDynamicTypeRampUndefined ? textAttributes->_dynamicTypeRamp
: _dynamicTypeRamp;
_letterSpacing = !isnan(textAttributes->_letterSpacing) ? textAttributes->_letterSpacing : _letterSpacing;

// Paragraph Styles
Expand Down Expand Up @@ -230,6 +232,12 @@ - (CGFloat)effectiveFontSizeMultiplier

if (fontScalingEnabled) {
CGFloat fontSizeMultiplier = !isnan(_fontSizeMultiplier) ? _fontSizeMultiplier : 1.0;
if (_dynamicTypeRamp != RCTDynamicTypeRampUndefined) {
UIFontMetrics *fontMetrics = RCTUIFontMetricsForDynamicTypeRamp(_dynamicTypeRamp);
// Using a specific font size reduces rounding errors from -scaledValueForValue:
CGFloat requestedSize = isnan(_fontSize) ? RCTBaseSizeForDynamicTypeRamp(_dynamicTypeRamp) : _fontSize;
fontSizeMultiplier = [fontMetrics scaledValueForValue:requestedSize] / requestedSize;
}
CGFloat maxFontSizeMultiplier = !isnan(_maxFontSizeMultiplier) ? _maxFontSizeMultiplier : 0.0;
return maxFontSizeMultiplier >= 1.0 ? fminf(maxFontSizeMultiplier, fontSizeMultiplier) : fontSizeMultiplier;
} else {
Expand Down Expand Up @@ -324,7 +332,7 @@ - (BOOL)isEqual:(RCTTextAttributes *)textAttributes
RCTTextAttributesCompareFloats(_fontSizeMultiplier) && RCTTextAttributesCompareFloats(_maxFontSizeMultiplier) &&
RCTTextAttributesCompareStrings(_fontWeight) && RCTTextAttributesCompareObjects(_fontStyle) &&
RCTTextAttributesCompareObjects(_fontVariant) && RCTTextAttributesCompareOthers(_allowFontScaling) &&
RCTTextAttributesCompareFloats(_letterSpacing) &&
RCTTextAttributesCompareOthers(_dynamicTypeRamp) && RCTTextAttributesCompareFloats(_letterSpacing) &&
// Paragraph Styles
RCTTextAttributesCompareFloats(_lineHeight) && RCTTextAttributesCompareFloats(_alignment) &&
RCTTextAttributesCompareOthers(_baseWritingDirection) && RCTTextAttributesCompareOthers(_lineBreakStrategy) &&
Expand Down
17 changes: 17 additions & 0 deletions Libraries/Text/Text.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ export interface TextPropsIOS {
*/
adjustsFontSizeToFit?: boolean | undefined;

/**
* The Dynamic Text scale ramp to apply to this element on iOS.
*/
dynamicTypeRamp?:
| 'caption2'
| 'caption1'
| 'footnote'
| 'subheadline'
| 'callout'
| 'body'
| 'headline'
| 'title3'
| 'title2'
| 'title1'
| 'largeTitle'
| undefined;

/**
* Specifies smallest possible scale a font can reach when adjustsFontSizeToFit is enabled. (values 0.01-1.0).
*/
Expand Down
37 changes: 37 additions & 0 deletions Libraries/Text/Text/RCTDynamicTypeRamp.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

#import <Foundation/Foundation.h>

#import <React/RCTConvert.h>

typedef NS_ENUM(NSInteger, RCTDynamicTypeRamp) {
RCTDynamicTypeRampUndefined,
RCTDynamicTypeRampCaption2,
RCTDynamicTypeRampCaption1,
RCTDynamicTypeRampFootnote,
RCTDynamicTypeRampSubheadline,
RCTDynamicTypeRampCallout,
RCTDynamicTypeRampBody,
RCTDynamicTypeRampHeadline,
RCTDynamicTypeRampTitle3,
RCTDynamicTypeRampTitle2,
RCTDynamicTypeRampTitle1,
RCTDynamicTypeRampLargeTitle
};

@interface RCTConvert (DynamicTypeRamp)

+ (RCTDynamicTypeRamp)RCTDynamicTypeRamp:(nullable id)json;

@end

/// Generates a `UIFontMetrics` instance representing a particular Dynamic Type ramp.
UIFontMetrics *_Nonnull RCTUIFontMetricsForDynamicTypeRamp(RCTDynamicTypeRamp dynamicTypeRamp);
/// The "reference" size for a particular font scale ramp, equal to a text element's size under default text size
/// settings.
CGFloat RCTBaseSizeForDynamicTypeRamp(RCTDynamicTypeRamp dynamicTypeRamp);
82 changes: 82 additions & 0 deletions Libraries/Text/Text/RCTDynamicTypeRamp.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

#import <React/RCTDynamicTypeRamp.h>

@implementation RCTConvert (DynamicTypeRamp)

RCT_ENUM_CONVERTER(
RCTDynamicTypeRamp,
(@{
@"caption2" : @(RCTDynamicTypeRampCaption2),
@"caption1" : @(RCTDynamicTypeRampCaption1),
@"footnote" : @(RCTDynamicTypeRampFootnote),
@"subheadline" : @(RCTDynamicTypeRampSubheadline),
@"callout" : @(RCTDynamicTypeRampCallout),
@"body" : @(RCTDynamicTypeRampBody),
@"headline" : @(RCTDynamicTypeRampHeadline),
@"title3" : @(RCTDynamicTypeRampTitle3),
@"title2" : @(RCTDynamicTypeRampTitle2),
@"title1" : @(RCTDynamicTypeRampTitle1),
@"largeTitle" : @(RCTDynamicTypeRampLargeTitle),
}),
RCTDynamicTypeRampUndefined,
integerValue)

@end

UIFontMetrics *RCTUIFontMetricsForDynamicTypeRamp(RCTDynamicTypeRamp dynamicTypeRamp)
{
static NSDictionary<NSNumber *, UIFontTextStyle> *mapping;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
mapping = @{
@(RCTDynamicTypeRampCaption2) : UIFontTextStyleCaption2,
@(RCTDynamicTypeRampCaption1) : UIFontTextStyleCaption1,
@(RCTDynamicTypeRampFootnote) : UIFontTextStyleFootnote,
@(RCTDynamicTypeRampSubheadline) : UIFontTextStyleSubheadline,
@(RCTDynamicTypeRampCallout) : UIFontTextStyleCallout,
@(RCTDynamicTypeRampBody) : UIFontTextStyleBody,
@(RCTDynamicTypeRampHeadline) : UIFontTextStyleHeadline,
@(RCTDynamicTypeRampTitle3) : UIFontTextStyleTitle3,
@(RCTDynamicTypeRampTitle2) : UIFontTextStyleTitle2,
@(RCTDynamicTypeRampTitle1) : UIFontTextStyleTitle1,
@(RCTDynamicTypeRampLargeTitle) : UIFontTextStyleLargeTitle,
};
});

id textStyle =
mapping[@(dynamicTypeRamp)] ?: UIFontTextStyleBody; // Default to body if we don't recognize the specified ramp
return [UIFontMetrics metricsForTextStyle:textStyle];
}

CGFloat RCTBaseSizeForDynamicTypeRamp(RCTDynamicTypeRamp dynamicTypeRamp)
{
static NSDictionary<NSNumber *, NSNumber *> *mapping;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// Values taken from
// https://developer.apple.com/design/human-interface-guidelines/foundations/typography/#specifications
mapping = @{
@(RCTDynamicTypeRampCaption2) : @11,
@(RCTDynamicTypeRampCaption1) : @12,
@(RCTDynamicTypeRampFootnote) : @13,
@(RCTDynamicTypeRampSubheadline) : @15,
@(RCTDynamicTypeRampCallout) : @16,
@(RCTDynamicTypeRampBody) : @17,
@(RCTDynamicTypeRampHeadline) : @17,
@(RCTDynamicTypeRampTitle3) : @20,
@(RCTDynamicTypeRampTitle2) : @22,
@(RCTDynamicTypeRampTitle1) : @28,
@(RCTDynamicTypeRampLargeTitle) : @34,
};
});

NSNumber *baseSize =
mapping[@(dynamicTypeRamp)] ?: @17; // Default to body size if we don't recognize the specified ramp
return CGFLOAT_IS_DOUBLE ? [baseSize doubleValue] : [baseSize floatValue];
}
1 change: 1 addition & 0 deletions Libraries/Text/TextNativeComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const textViewConfig = {
numberOfLines: true,
ellipsizeMode: true,
allowFontScaling: true,
dynamicTypeRamp: true,
maxFontSizeMultiplier: true,
disabled: true,
selectable: true,
Expand Down
17 changes: 17 additions & 0 deletions Libraries/Text/TextProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,23 @@ export type TextProps = $ReadOnly<{|
*/
adjustsFontSizeToFit?: ?boolean,

/**
* The Dynamic Text scale ramp to apply to this element on iOS.
*/
dynamicTypeRamp?: ?(
| 'caption2'
| 'caption1'
| 'footnote'
| 'subheadline'
| 'callout'
| 'body'
| 'headline'
| 'title3'
| 'title2'
| 'title1'
| 'largeTitle'
),

/**
* Smallest possible scale a font can reach.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ void TextAttributes::apply(TextAttributes textAttributes) {
allowFontScaling = textAttributes.allowFontScaling.has_value()
? textAttributes.allowFontScaling
: allowFontScaling;
dynamicTypeRamp = textAttributes.dynamicTypeRamp.has_value()
? textAttributes.dynamicTypeRamp
: dynamicTypeRamp;
letterSpacing = !std::isnan(textAttributes.letterSpacing)
? textAttributes.letterSpacing
: letterSpacing;
Expand Down Expand Up @@ -111,6 +114,7 @@ bool TextAttributes::operator==(const TextAttributes &rhs) const {
fontStyle,
fontVariant,
allowFontScaling,
dynamicTypeRamp,
alignment,
baseWritingDirection,
lineBreakStrategy,
Expand All @@ -131,6 +135,7 @@ bool TextAttributes::operator==(const TextAttributes &rhs) const {
rhs.fontStyle,
rhs.fontVariant,
rhs.allowFontScaling,
rhs.dynamicTypeRamp,
rhs.alignment,
rhs.baseWritingDirection,
rhs.lineBreakStrategy,
Expand Down Expand Up @@ -186,6 +191,7 @@ SharedDebugStringConvertibleList TextAttributes::getDebugProps() const {
debugStringConvertibleItem("fontStyle", fontStyle),
debugStringConvertibleItem("fontVariant", fontVariant),
debugStringConvertibleItem("allowFontScaling", allowFontScaling),
debugStringConvertibleItem("dynamicTypeRamp", dynamicTypeRamp),
debugStringConvertibleItem("letterSpacing", letterSpacing),

// Paragraph Styles
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class TextAttributes : public DebugStringConvertible {
std::optional<FontStyle> fontStyle{};
std::optional<FontVariant> fontVariant{};
std::optional<bool> allowFontScaling{};
std::optional<DynamicTypeRamp> dynamicTypeRamp{};
Float letterSpacing{std::numeric_limits<Float>::quiet_NaN()};
std::optional<TextTransform> textTransform{};

Expand Down
78 changes: 78 additions & 0 deletions ReactCommon/react/renderer/attributedstring/conversions.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,84 @@
namespace facebook {
namespace react {

inline std::string toString(const DynamicTypeRamp &dynamicTypeRamp) {
switch (dynamicTypeRamp) {
case DynamicTypeRamp::Caption2:
return "caption2";
case DynamicTypeRamp::Caption1:
return "caption1";
case DynamicTypeRamp::Footnote:
return "footnote";
case DynamicTypeRamp::Subheadline:
return "subheadline";
case DynamicTypeRamp::Callout:
return "callout";
case DynamicTypeRamp::Body:
return "body";
case DynamicTypeRamp::Headline:
return "headline";
case DynamicTypeRamp::Title3:
return "title3";
case DynamicTypeRamp::Title2:
return "title2";
case DynamicTypeRamp::Title1:
return "title1";
case DynamicTypeRamp::LargeTitle:
return "largeTitle";
}

LOG(ERROR) << "Unsupported DynamicTypeRamp value";
react_native_assert(false);

// Sane default in case of parsing errors
return "body";
}

inline void fromRawValue(
const PropsParserContext &context,
const RawValue &value,
DynamicTypeRamp &result) {
react_native_assert(value.hasType<std::string>());
if (value.hasType<std::string>()) {
auto string = (std::string)value;
if (string == "caption2") {
result = DynamicTypeRamp::Caption2;
} else if (string == "caption1") {
result = DynamicTypeRamp::Caption1;
} else if (string == "footnote") {
result = DynamicTypeRamp::Footnote;
} else if (string == "subheadline") {
result = DynamicTypeRamp::Subheadline;
} else if (string == "callout") {
result = DynamicTypeRamp::Callout;
} else if (string == "body") {
result = DynamicTypeRamp::Body;
} else if (string == "headline") {
result = DynamicTypeRamp::Headline;
} else if (string == "title3") {
result = DynamicTypeRamp::Title3;
} else if (string == "title2") {
result = DynamicTypeRamp::Title2;
} else if (string == "title1") {
result = DynamicTypeRamp::Title1;
} else if (string == "largeTitle") {
result = DynamicTypeRamp::LargeTitle;
} else {
// sane default
LOG(ERROR) << "Unsupported DynamicTypeRamp value: " << string;
react_native_assert(false);
result = DynamicTypeRamp::Body;
}
return;
}

LOG(ERROR) << "Unsupported DynamicTypeRamp type";
react_native_assert(false);

// Sane default in case of parsing errors
result = DynamicTypeRamp::Body;
}

inline std::string toString(const EllipsizeMode &ellipsisMode) {
switch (ellipsisMode) {
case EllipsizeMode::Clip:
Expand Down
Loading

0 comments on commit 11c8bf3

Please sign in to comment.