Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement baseline alignment function on the new architecture #45102

Closed

Conversation

j-piasecki
Copy link
Collaborator

@j-piasecki j-piasecki commented Jun 21, 2024

Summary:

On the new architecture, the setup that would allow Yoga to read the baseline of a node was missing. This PR adds it:

  • adds new ShadowNode trait - BaselineYogaNode that marks a node as having a custom baseline function
  • adds yogaNodeBaselineCallbackConnector that's responsible for allowing Yoga to call baseline function on the node
  • changes signatures of lastBaseline and firstBaseline to accept LayoutContext as the first argument, which is necessary to build an attributed string
  • adds implementation of lastBaseline that's invoked by yogaNodeBaselineCallbackConnector
  • adds methods for calculating the last baseline in platform-specific TextLayoutManagers, using the same approach on both Android and iOS (this differs from the old architecture where calculations were different)

Changelog:

[GENERAL] [FIXED] - Fixed alignItems: 'baseline' not working correctly on the new architecture

Test Plan:

Tested on the relevant part of RNTester:

Android

Old arch New arch before New arch after
baseline-android-old-arch baseline-android-new-arch-before Screenshot 2024-07-02 at 16 40 38

iOS

Old arch New arch before New arch after
baseline-ios-old-arch baseline-ios-new-arch-before Screenshot 2024-07-02 at 16 40 29

@facebook-github-bot facebook-github-bot added CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Jun 21, 2024
@cortinico
Copy link
Contributor

/rebase

@cortinico
Copy link
Contributor

Autorebase failed as we're doing GHA work at the moment. @j-piasecki if you could rebase the CI should be green

@j-piasecki j-piasecki force-pushed the @jpiasecki/baseline-func-new-arch branch from 763988c to 00ccf02 Compare June 21, 2024 20:43
@analysis-bot
Copy link

analysis-bot commented Jun 21, 2024

Platform Engine Arch Size (bytes) Diff
android hermes arm64-v8a 20,383,026 -902,098
android hermes armeabi-v7a n/a --
android hermes x86 n/a --
android hermes x86_64 n/a --
android jsc arm64-v8a 23,579,561 -902,438
android jsc armeabi-v7a n/a --
android jsc x86 n/a --
android jsc x86_64 n/a --

Base commit: 8c8c77b
Branch: main

Copy link
Contributor

@NickGerleman NickGerleman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add an RNTester example for this? edit: missed we had the existing one shown in test plan

Comment on lines 755 to 756
Layout layout =
createLayout(context, attributedString, paragraphAttributes, width, height, null);
Copy link
Contributor

@NickGerleman NickGerleman Jun 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will recreate AttributedString MappBuffer, Spannable, and relayout, which can be expensive. It would be great if we could reuse some of these, but I'm not sure this is nice to do with current interface, and needing to calculate baseline is not the most common case.

At some point in the future, I am looking to change this code a bit, so that we use PrecomputedText, which associates some glyph-level measurement information with the Spannable when we layout (so that later, the UI thread TextView does not need to relayout after Fabric layout, which can cause scroll jank). I haven't thought about how to organize this yet though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anything I should do regarding this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, we can leave it. I have some ideas here (like riffing off the spannable IDs used in other places for reuse, or batching baseline and measure together), that I might try to take a stab at later.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, though I think the Fabric LineMeasurements would contain everything already needed to determine a baseline.

Right now I don't remember that we store line measurements against contraints (my guess it we don't), but it means baseline calculation could rely on information already retrieved most of the time, by measure.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, though I think the Fabric LineMeasurements would contain everything already needed to determine a baseline.

I'm pretty sure you're right

Right now I don't remember that we store line measurements against contraints (my guess it we don't), but it means baseline calculation could rely on information already retrieved most of the time, by measure.

It's not cached at the moment. Is this close to what you have in mind: 0ea302c?

@@ -504,6 +504,20 @@ private NativeArray measureLines(
PixelUtil.toPixelFromDIP(height));
}

@SuppressWarnings("unused")
private float getLastBaseline(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does Paper use last line? Spec kinda seems to suggest inline-flex (how RN paragraph mostly works) would align to first line of multiline context. https://www.w3.org/TR/css-inline-3/#baseline-source

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it does:

Comment on lines 758 to 763
float maxDescent = 0;
for (int i = 0; i < layout.getLineCount(); i++) {
if (maxDescent < layout.getLineDescent(i)) {
maxDescent = layout.getLineDescent(i);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to loop through these? My assumption would be that we always want to use descent of the last line (or, first line, depending on above comment).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's to align with the implementation used on iOS:

[attributedText enumerateAttribute:NSFontAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
if (maximumDescender > font.descender) {
maximumDescender = font.descender;
}
}];
return size.height + maximumDescender;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming is confusing me here a little bit. I am wondering if these are doing the same things.

A "descender" is part of text that goes under the baseline. So, getting lowest descender, is I think trying to get the bottom of the text. Though, this isn't really the baseline.

image

But a "descent" usually is the distance between the baseline and the bottom of the line-box. StackOverflow suggests that, this value on Android layout does not have any sort of per-line offset. https://stackoverflow.com/a/43691403

image

I think the logic works for single-line, but I don't think the Android logic will work correctly for multiline containers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bet is that iOS might have been using largest font, to calculate offset from bottom, to approximate the last line height, that we have the direct access to on Android.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a difference though, when padding is applied to TextInput. It's included in height but the Java part is unaware of it.

Comment on lines 156 to 157
virtual Float firstBaseline(Size size) const;
virtual Float lastBaseline(Size size) const;
virtual Float firstBaseline(const LayoutContext& layoutContext, Size size) const;
virtual Float lastBaseline(const LayoutContext& layoutContext, Size size) const;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's existing code, but I think this could potentially just be a single function, since rules around multiple baselines, and which to pick, is text-specific concept/implementation detail. If we only give a single baseline function to Yoga, having one baseline function to override which isn't called could be confusing.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want me to remove the firstBaseline?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had in mind replacing both with a single baseline function, where line selection would be a detail of it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - f576413

@NickGerleman
Copy link
Contributor

NickGerleman commented Jun 25, 2024

The way Paper is handling multi-line baseline selection in flex containers seems inconsistent with browsers/flexbox spec. In browser, first-line is chosen. https://jsfiddle.net/jq49r8hb/

I suspect we could get away with changing the behavior in RN to align with the spec and browsers. But I'm curious what @cortinico thinks about this.

@j-piasecki
Copy link
Collaborator Author

j-piasecki commented Jun 25, 2024

I've also updated the relevant Android part of RNTester to match what's on iOS:

Screenshot 2024-06-25 at 16 30 06 Screenshot 2024-06-25 at 16 20 22

@NickGerleman
Copy link
Contributor

@j-piasecki I think this is looking good. Have we tested/do we have validation for multi-line?

I still kinda think we should switch Paper behavior for web behavior though, now that we have the chance to 😅.

@facebook-github-bot
Copy link
Contributor

@cortinico has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

@cortinico
Copy link
Contributor

@j-piasecki this is failing to build internally with:

[...]\packages\react-native\ReactCommon\react\renderer\components\text\ParagraphShadowNode.cpp(188,30): error: no member named 'getLastBaseline' in 'facebook::react::TextLayoutManager'
  return textLayoutManager_->getLastBaseline(
         ~~~~~~~~~~~~~~~~~~~~^

@j-piasecki
Copy link
Collaborator Author

That's weird, it looks like it's pointing here:

return textLayoutManager_
->baseline(
attributedString,
getConcreteProps().paragraphAttributes,
size);
but I already updated it. Moreover, there are no references to getLastBaseline at all in the codebase at the moment. Is it possible that it's caused by a stale cache?

@cortinico
Copy link
Contributor

Moreover, there are no references to getLastBaseline at all in the codebase at the moment. Is it possible that it's caused by a stale cache?

@j-piasecki my bad I copied the error message form a old revision.

The error is still:

[...]\packages\react-native\ReactCommon\react\renderer\components\text\ParagraphShadowNode.cpp(188,30): error: no member named 'baseline' in 'facebook::react::TextLayoutManager'
  return textLayoutManager_->baseline(
         ~~~~~~~~~~~~~~~~~~~~^

@@ -303,12 +305,12 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage

CGSize attachmentSize = attachment.bounds.size;
CGRect glyphRect = [layoutManager boundingRectForGlyphRange:range inTextContainer:textContainer];

CGFloat baseline = [layoutManager locationForGlyphAtIndex:range.location].y;
UIFont *font = [textStorage attribute:NSFontAttributeName atIndex:range.location effectiveRange:nil];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This variable is unused now and is causing various iOS builds to fail (we do have -Wall everywhere)

@j-piasecki
Copy link
Collaborator Author

The error is still:
[...]\packages\react-native\ReactCommon\react\renderer\components\text\ParagraphShadowNode.cpp(188,30): error: no member named 'baseline' in 'facebook::react::TextLayoutManager' return textLayoutManager_->baseline( ~~~~~~~~~~~~~~~~~~~~^

I don't see why it would fail there. All TextLayoutManager headers (for ios, android, and cxx) have the baseline method declared in the public scope and all of them have the implementation.

One thing I noticed is that I was using float type instead of Float. I think the error message would be different if that was the cause, but I changed it nonetheless.

@facebook-github-bot
Copy link
Contributor

@cortinico has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

@cortinico
Copy link
Contributor

I don't see why it would fail there. All TextLayoutManager headers (for ios, android, and cxx) have the baseline method declared in the public scope and all of them have the implementation.

Ok so the issue is that we do have other TextLayoutManager in our codebase that are not exposed to GitHub. I'll take care of providing a baseline function there and returning 0 so that should unblock us 👍

@NickGerleman
Copy link
Contributor

NickGerleman commented Jul 5, 2024

The one problem left is that the baseline is "cascading" when nesting multiple views and texts together

As mentioned above, this is caused by every attachment being positioned on the baseline, so views containing text are effectively moved up by the descent. This one might be tricky to solve - I think it would need to align with the last baseline of the attached view.

@j-piasecki Sorry, I missed reading this in the last iteration.

In an inline/text context, items are by default aligned so that their baselines mare matched. I think the "cascading" problem is an artifact of always treating the baseline of the inline view as its bottom.

A flexbox container's baseline is set based on its children. So, the baseline of that blue "View", should really match the baseline of the text inside of it.

  1. https://www.w3.org/TR/css-flexbox-1/#flex-baselines
  2. https://www.w3.org/TR/css-align-3/#baseline-export

Yoga does implement this algorithm, for determining the baseline of a child when using align-items: baseline, but React Native's text layout doesn't do anything similar for positioning attachments, so nothing will be seen for that. https://github.com/facebook/yoga/blob/5009f5c1accb3dde14e1e18930df70c18c70dc75/yoga/algorithm/Baseline.cpp#L17

Not something for this PR, but would definitely be welcome as followup if you ever feel like doing more around this area.

@facebook-github-bot
Copy link
Contributor

@cortinico has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

@j-piasecki
Copy link
Collaborator Author

In an inline/text context, items are by default aligned so that their baselines mare matched. I think the "cascading" problem is an artifact of always treating the baseline of the inline view as its bottom.

That's exactly what's happening.

Yoga does implement this algorithm, for determining the baseline of a child when using align-items: baseline, but React Native's text layout doesn't do anything similar for positioning attachments, so nothing will be seen for that. https://github.com/facebook/yoga/blob/5009f5c1accb3dde14e1e18930df70c18c70dc75/yoga/algorithm/Baseline.cpp#L17

Not something for this PR, but would definitely be welcome as followup if you ever feel like doing more around this area.

Sure, I would be up for it, though I don't think it would be contained to the React Native's part of text layout. Every inline view is passed as an attachment which is essentially a rectangle without any information about it's children or baseline.

Moreover, it looks like inline views are aligned on the last baseline instead of the first one: https://jsfiddle.net/tgkdr63s/

@NickGerleman
Copy link
Contributor

Moreover, it looks like inline views are aligned on the last baseline instead of the first one: https://jsfiddle.net/tgkdr63s/

If you change inline-block to inline-flex this changes (baseline determination is different for flex containers vs block containers) See #45102 (comment)

@NickGerleman
Copy link
Contributor

From https://jsfiddle.net/jq49r8hb/ I think the first baseline behavior will also still happen when aligning inline-block element in flex container too. I think the last baseline behavior may be specific to inline-block inside block context?

@facebook-github-bot
Copy link
Contributor

@cortinico merged this pull request in 2932c0f.

@facebook-github-bot facebook-github-bot added the Merged This PR has been merged. label Jul 10, 2024
Copy link

This pull request was successfully merged by @j-piasecki in 2932c0f.

When will my fix make it into a release? | How to file a pick request?

@SupertigerDev
Copy link

SupertigerDev commented Jul 29, 2024

why is something like this not possible still?

https://jsfiddle.net/yhc43e9w/1/

Aligning the box in the center.

facebook-github-bot pushed a commit that referenced this pull request Aug 22, 2024
Summary:
In #45102 I've implemented a baseline alignment function for the new architecture. I've noticed one thing I've missed previously - `locationForGlyphAtIndex` is [relative to the line fragment](https://developer.apple.com/documentation/appkit/nslayoutmanager/1403239-locationforglyphatindex), not the container. This means that the attachments would be put in the wrong place in multiline text.

This PR fixes that by adding the position of the entire line to the attachment position.

## Changelog:

[IOS] [FIXED] - Fixed baseline attachment position in multiline text

Pull Request resolved: #46172

Test Plan:
Checked on relevant example of RNTester.

|Before|After|
|-|-|
|<img width="546" alt="Screenshot 2024-08-22 at 15 53 14" src="https://github.com/user-attachments/assets/c1861655-9253-44fd-9f2f-796aff83df1e">|<img width="546" alt="Screenshot 2024-08-22 at 15 52 37" src="https://github.com/user-attachments/assets/27f07bc7-a8f6-4696-a414-815e7ece642c">|

Reviewed By: andrewdacenko

Differential Revision: D61662006

Pulled By: cipolleschi

fbshipit-source-id: 5eafdae1800c06d9fc61bfac99584e6e25a05c24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Merged This PR has been merged. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants