Flutter difficult and miscellaneous diseases series: realize the vertical centering of Chinese text

Keywords: Android

Author: byte hopping terminal technology -- Lin Xuebin

1, Background

Since we often have button scenarios in business development, we require that the description text in the UI should be vertically centered as much as possible. However, in the process of development, we often encounter the problem that the text is not vertically centered as shown in the figure below, and we need to set the Padding attribute additionally. However, with the changes of font size, mobile phone screen density and other factors, the Padding value also needs to be adjusted, which requires our R & D personnel to invest some energy in adaptation.

2, Font key information

2.1 font key information

If our fluent application does not specify a custom font, it will Fallback to the system default font. What is the default font? Take Android as an example, on the device  / system/etc/fonts.xml   Relevant matching rules are recorded in the file, and the corresponding fonts are stored in the  / system/fonts   Yes. According to the following rules, the Chinese text in our daily application will match as   NotoSansCJK-Regular   (Siyuan BOLD) font.

<family lang="zh-Hans">
    <font weight="400" style="normal" index="2">NotoSansCJK-Regular.ttc</font>
    <font weight="400" style="normal" index="2" fallbackFor="serif">NotoSerifCJK-Regular.ttc</font>
</family>

Note: we can create an Android simulator and obtain the above information through adb command

Then we use   font-line   Tool to get font related information.

pip3 install font-line # install
font-line report ttf_path # get ttf font info

The information obtained   NotoSansCJK-Regular   The key information is as follows:

[head] Units per Em:  1000
[head] yMax:      1808
[head] yMin:     -1048
[OS/2] CapHeight:   733
[OS/2] xHeight:    543
[OS/2] TypoAscender:  880
[OS/2] TypoDescender: -120
[OS/2] WinAscent:   1160
[OS/2] WinDescent:   320
[hhea] Ascent:     1160
[hhea] Descent:    -320

[hhea] LineGap:    0
[OS/2] TypoLineGap:  0

There are many entries in the above log, which can be accessed through https://glyphsapp.com/learn/vertical-metrics We can know that the information represented by HHEA (horizontal typesetting header) is used on Android devices, so the key information can be extracted as follows:

[head] Units per Em:  1000
[head] yMax:      1808
[head] yMin:     -1048
[hhea] Ascent:     1160
[hhea] Descent:    -320
[hhea] LineGap:    0

Is it still confused? It's OK. You can understand it clearly by reading the figure below.

In the figure above, the three most critical lines are:   baseline,Ascent   and   Descent. baseline   It can be understood as our horizontal line. Generally   Ascent   and   Descent   Represent the upper and lower limits of the font drawing area respectively. stay   NotoSansCJK-Regular   In the information, we see   yMax   and   yMin   Corresponding to the in the figure   Top   and   Bottom represents the upper and lower limits of the y-axis in all glyphs contained in this font. In addition, we also see   LineGap   Parameter, which corresponds to the   Leading, used to control the size of row spacing.

In addition, we have not mentioned an important parameter   Units per Em   Sometimes we call it short   Em, this parameter is used to normalize the relevant information of the font.

For example, in fluent, we set the fontSize of the font to 10. In addition, the density of the device is 3. How high is the font?

adopt   fontEditor   We can get the following figure:

It can be seen from the above figure that the upper vertex coordinates of the word "medium" are (459, 837) and the lower vertex coordinates are (459, - 76), so the height of the word "medium" is (837 + 76) = 913. It can be seen from the above NotoSans font information,   The Em value is 1000, so the "medium" word height of each unit is 0.913, and the ascent and descent are 1160 and - 320 described above.

Here again, if we use NotoSans font on a device with a screen density of 3, if the fontSize of "medium" is set to 10, then

  • The height of "middle" shape is: 10 * 3 * 0.913 = 27.39 ~ = 27
  • Text border height: 10 * 3 * (1160 + 320) / 1000 = 44

That is, when fontSize is set to 30 pixels, the height of "medium" word is 27 pixels and the height of text box is 44 pixels.

2.2 why not center vertically

As can be seen from the previous section, if LineGap is 0, that is, if Leading is 0, then the vertical layout of text in fluent is only related to ascent and descent, that is:

height = (accent - descent) / em * fontSize

According to the "middle" sub figure in Section 2.1:

  • The center of the "middle" font is at (837 + - 76) / 2 = 380
  • The centers of ascent and descent of "medium" are (1160 + - 320) / 2 = 420

If fontSize is 10, on the device with density 3, 10 * 3 * (420 - 380) / 1000 = 1.2 ~ = 1, the center point has a deviation of 1 pixel. The larger the font size is, the greater the deviation will be. Therefore, it is impossible to realize the vertical centering of text by directly using NotoSans information for vertical layout.

Is there any other way besides using Padding? Or let's change our perspective, because many design principles of fluent are very similar to Android, so let's first refer to the current implementation of Android.

3, How does Android native center text vertically

At present, in addition to using Padding in Android, there are two feasible solutions:

  • Set TextView's   includeFontPadding   by   false
  • The custom View calls the Paint.getTextBounds() method to get the bounds of the String

3.1 includeFontPadding enables text centering

In Android, textview   By default   yMax   and   yMin   As the upper edge and edge of the text box, if   TextView   of   includeFontPadding   Set to   false   Use only after   Ascent   and   Descent   The upper and lower edges of the.

We can   android/text/BoringLayout.java   Find the logic in the init method of.

void init(CharSequence source, TextPaint paint, Alignment align,
        BoringLayout.Metrics metrics, boolean includePad, boolean trustWidth) {
    // ...
    //  as well as   if   includePad   by   true    Then   bottom   and   top   Subject to
    //     if   includePad   by   false   Then   ascent   and   descent   Subject to
    if (includePad) {
        spacing = metrics.bottom - metrics.top;
        mDesc = metrics.bottom;
    } else {
        spacing = metrics.descent - metrics.ascent;
        mDesc = metrics.descent;
    }
    // ...
 }

For further verification, we exported the notosanscjk regular of the system and put it into the Android project. Then we set the android:fontFamily property of TextView to the font, and then something unexpected happened.

The above figure shows the difference between the text matching system's default notosanscjk regular font (left figure) and the notosanscjk regular font specified through android:fontFamily (right figure) after the includeFontPadding attribute of TextView is set to false. If a font is used, the two should be exactly the same in theory, but the current results are not the same.

Through breakpoint debugging, we found the getfontmetricint method in android/graphics/Paint.java to obtain the Metrics containing font information:

public int getFontMetricsInt(FontMetricsInt fmi) {
    return nGetFontMetricsInt(mNativePaint, fmi);
}

Experiment 1. By default, we obtained the following information:

FontMetricsInt: top=-111 ascent=-97 descent=26 bottom=29 leading=0 width=0

Experiment 2: after setting android:fontFamliy to NotoSans, we got the following results:

FontMetricsInt: top=-190 ascent=-122 descent=30 bottom=111 leading=0 width=0

In Experiment 3, after setting android:fontFamliy as robot, we got the following results:

FontMetricsInt: top=-111 ascent=-97 descent=26 bottom=29 leading=0 width=0

Note 1: the above data is in the Pixel simulator. The font is set to 40dp and the DPI is 420. Note 2: robot is the font matched with digital English

From the above three experiments, we can see that TextView uses the robot information as its layout information by default, and the Chinese finally matches the NotoSans font. In this case, the text happens to be centered, so this is not the scheme we pursue\

3.2 center text with paint. Gettextbounds()

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    Paint paint = new Paint();
    paint.setColor(0xFF03DAC5);
    Rect r = new Rect();
    //  Set font size
    paint.setTextSize(dip2px(getContext(), fontSize));
    //  Get font bounds
    paint.getTextBounds(str, 0, str.length(), r);
    float offsetTop = -r.top;
    float offsetLeft = -r.left;
    r.offset(-r.left, -r.top);
    paint.setAntiAlias(true);
    canvas.drawRect(r, paint);
    paint.setColor(0xFF000000);
    canvas.drawText(str, offsetLeft, offsetTop, paint);
}

\

The above code is the logic of our operation. Here we need to explain the value of Rect obtained. The screen coordinates take the upper left corner as the origin and the downward direction is the positive direction of the Y axis. The font drawing is based on the baseline. Compared with the whole Rect, the baseline is the origin of its own Y axis, so the top above the baseline is negative and the bottom below the baseline is positive.

The core of the above custom View is the getTextBounds function. As long as we can interpret the information inside, we can crack the scheme. Fortunately, Android is open source. We found the following implementation in frameworks/base/core/jni/android/graphics/Paint.cpp:

static void getStringBounds(JNIEnv* env, jobject, jlong paintHandle, jstring text, jint start,
        jint end, jint bidiFlags, jobject bounds) {
    //  Omit several codes  ...
    doTextBounds(env, textArray + start, end - start, bounds, *paint, typeface, bidiFlags);
    env->ReleaseStringChars(text, textArray);
}

static void doTextBounds(JNIEnv* env, const jchar* text, int count, jobject bounds,
        const Paint& paint, const Typeface* typeface, jint bidiFlags) {

    //  Omit several codes  ...
    minikin::Layout layout = MinikinUtils::doLayout(&paint,
            static_cast<minikin::Bidi>(bidiFlags), typeface,
            text, count,  // text buffer
            0, count,  // draw range
            0, count,  // context range
            nullptr);
    minikin::MinikinRect rect;
    layout.getBounds(&rect);
    //  Omit several codes  ...
}

Next, let's look at frameworks/base/libs/hwui/hwui/MinikinUtils.cpp:

minikin::Layout MinikinUtils::doLayout(const Paint* paint, minikin::Bidi bidiFlags,
                                    const Typeface* typeface, const uint16_t* buf,
                                    size_t bufSize, size_t start, size_t count,
                                    size_t contextStart, size_t contextCount,
                                    minikin::MeasuredText* mt) {
    minikin::MinikinPaint minikinPaint = prepareMinikinPaint(paint, typeface);
    //  Omit several codes  ... 
    return minikin::Layout(textBuf.substr(contextRange), range - contextStart, bidiFlags,
}

To sum up, the core is to obtain the Bounds by calling the Layout interface of minikin, and the logic related to Flutter is very similar to Android, so this scheme can be applied to Flutter.

4, Centering text in fluent

4.1 relevant principles and modification instructions

It can be seen from section 3.2 that if you want to center the text in the shuttle according to the idea of getTextBounds of Android, the core is to call the minikin:Layout method. We find the following call links in the existing layout logic of the shuttle:

ParagraphTxt::Layout()
    -> Layout::doLayout()
        -> Layout::doLayoutRunCached()
            -> Layout::doLayoutWord()
                ->LayoutCacheKey::doLayout()
                    -> Layout::doLayoutRun()
                        -> MinikinFont::GetBounds()
                            -> FontSkia::GetBounds()
                                -> SkFont::getWidths()
                                    -> SkFont::getWidthsBounds()

among   SkFont::getWidthsBounds   As follows:

void SkFont::getWidthsBounds(const SkGlyphID glyphIDs[],
                             int count,
                             SkScalar widths[],
                             SkRect bounds[],
                             const SkPaint* paint) const {
    SkStrikeSpec strikeSpec = SkStrikeSpec::MakeCanonicalized(*this, paint);
    SkBulkGlyphMetrics metrics{strikeSpec};
    //  Get the corresponding glyph
    SkSpan<const SkGlyph*> glyphs = metrics.glyphs(SkMakeSpan(glyphIDs, count));
    SkScalar scale = strikeSpec.strikeToSourceRatio();

    if (bounds) {
        SkMatrix scaleMat = SkMatrix::Scale(scale, scale);
        SkRect* cursor = bounds;
        for (auto glyph : glyphs) {
            //  be careful   glyph->rect()   The values inside are   int   type
            scaleMat.mapRectScaleTranslate(cursor++, glyph->rect());
        }
    }

    if (widths) {
        SkScalar* cursor = widths;
        for (auto glyph : glyphs) {
            *cursor++ = glyph->advanceX() * scale;
        }
    }
}

Therefore, according to the idea of getTextBounds, there will be no additional layout consumption. We just need to pass the data stored in the above link

Layout::getBounds(MinikinRect* bounds)   Function call to get and.

In the process of implementation, the following points for attention are encountered:

  • The Size used in the Flutter mapping is really the fontSize set in the Dart layer. Compared with the fontSize x density of Android, it will cause the loss of accuracy and the deviation of 1 ~ density pixels - so it needs to be enlarged accordingly
  • In ParagraphTxt::Layout, if height is calculated as round (max_access + max_descent), precision will be lost
  • In ParagraphTxt::Layout, y_offset, that is, the y-axis position of the baseline when drawing, also has the problem of loss of accuracy
  • Paragraph obtains the height interface at the Dart layer and calls_ applyFloatingPointHack, i.e. value.ceilToDouble(), such as 0.0001 - > 1.0, requires additional main parameters in the process of bottom accuracy adaptation

We also proposed the corresponding PR to the official and realized it   forceVerticalCenter   Function, see: https://github.com/flutter/engine/pull/27278

4.2 result verification

The difference from the official PR is that in the internal version, we provide the drawMinHeight parameter. Because there are a lot of modifications to realize this part of the function, we are not going to propose PR to the official for the time being.

In Text, we added two parameters:

  • drawMinHeight: draws the minimum height
  • forceVerticalCenter: forces the text to be centered vertically in this line while keeping other existing related logic unchanged

Figure 4-1 comparison between normal mode (left) and drawMinHeight (right) of FontSize from 8 to 26 on Android side

Figure 4-2 comparison between normal mode (left) and forceVerticalCenter (right) of FontSize from 8 to 26 on Android side

5, Summary

Through the interpretation of the key information of the font, this paper makes the readers have a general impression on the layout of the font in the vertical direction. Taking the word "Zhong" as an example, this paper analyzes the information of NotoSans, and points out the root problem of not being centered. Then it explores two native solutions of Android and analyzes the principle of them. Finally, based on the principle of getTextBounds scheme of Android, the function of forceVerticalCenter is implemented on fluent.

At present, Flutter is still growing rapidly, and there are more or less difficult experience problems. The Flutter Infra team is working to solve these difficult and miscellaneous problems. This paper mainly solves the problem of middle alignment of Flutter's text. A series of articles on the treatment of Flutter's difficult and miscellaneous diseases will be output later. Please pay attention.

reference material

<!---->

About byte terminal technology team

The client infrastructure is a global R & D team of large front-end basic technology (with R & D teams in Beijing, Shanghai, Hangzhou, Shenzhen, Guangzhou, Singapore and mountain view city in the United States respectively), which is responsible for the construction of large front-end infrastructure of the whole byte beating and improving the performance, stability and engineering efficiency of the company's whole product line; The products include, but not limited to, the shaking, the headlines, the watermelon videos, the flying books, the tiktok, etc., and have in-depth studies in the mobile terminals, Web, Desktop terminals.

Right now! Client / front end / server / intelligent algorithm / Test Development   For global recruitment! Join us to change the world with technology. If you are interested, you can contact email   [ chenxuwei.cxw@bytedance.com ], message subject   Resume - name - job intention - expected city - telephone.

Volcanic engine application development kit MARS It is a bytes beating terminal technology team in the past nine years in the research and development practice of App, such as jitter, headline, watermelon video, flying book, and tiktok, and mobile R & D, front-end development, QA, operation and maintenance, product manager, project manager and operation role. It provides one-stop overall R & D solutions, helping enterprises upgrade their R & D mode and reduce the overall cost of R & D. can Click the link Enter the official website to view more product information.

Posted by sajlent on Tue, 16 Nov 2021 22:02:55 -0800