Skip to content
academia.sh

Lesson 12 / 19

Assets and Fonts

The font scale enlarges the request of a button, and when the growing request exceeds the range the button is clamped. The lesson gives the scale as 1, 2, and 3 and measures how widening the range changes the result.

Published Updated

In this lesson

  • Tells the asset declaration, the image resolution variants, and the font scale apart
  • Measures that the font scale enlarges the request of a button
  • Shows that the button is chosen at scale 1 and clamped at scale 2
  • Shows that the same request can be chosen again when the range widens
Contents

The previous lesson showed that a saved preference removed the request of a card and made the card stretched. This lesson measures an input that does the opposite: an input that enlarges the request. When the user enlarges the text size in the system settings, every text in the app and every button that carries text grows. The course question is the same again: who determines this widget’s width? The lesson looks for the answer through a button that grows with the scale.

Assets, Resolution Variants, and Fonts

In this lesson an asset is a file packaged together with the app: an image, a font file, or a sound. It has nothing to do with an entity in a data model. The app configuration declares which files are assets; a file that is not declared does not enter the package.

Images come with resolution variants according to the density of the screen: enlarged files of the same image, such as 1.0x and 2.0x, stand side by side, and the framework chooses the one that fits the screen. Fonts are also assets; they are declared under a family name.

The Core

Every lesson uses the same core. measure walks the tree with a tight constraint at the screen width, produces a record for each node, and gives the sizing in the record by a rule: if the constraint is tight, forced; if the request stays inside the range, chosen; if the request falls outside the range, or there is no request and there is no upper bound, clamped; if there is no request and the upper bound is finite, stretched. The four names are written in ASCII in the output: forced, chosen, clamped, stretched. The parent: line is the total of forced, clamped, and stretched; it counts the records whose width is not the widget’s own request.

// sizing.dart — classifies who decides a widget's width
const sizings = ['forced', 'chosen', 'clamped', 'stretched'];

class Limit {
  final int min;
  final int? max; // null: unbounded
  const Limit(this.min, this.max);

  bool get tight => max == min;
  Limit loosen() => Limit(0, max);
  Limit deflate(int by) => Limit(_floor(min - by), max == null ? null : _floor(max! - by));
  String bounds() => '$min..${max ?? 'inf'}';
}

int _floor(int value) => value < 0 ? 0 : value;

class Part {
  final String kind;
  final String name;
  final int? width; // null: as wide as it can get
  final List<Part> children;
  final int factor;
  final bool tightFlex;
  const Part(this.kind, this.name, this.width, this.children,
      {this.factor = 0, this.tightFlex = true});
}

Part leaf(String name, int width) => Part('leaf', name, width, const []);
Part fill(String name) => Part('leaf', name, null, const []);
Part pad(String name, int padding, Part child) => Part('pad', name, padding, [child]);
Part center(String name, Part child) => Part('center', name, null, [child]);
Part fixed(String name, int width, Part child) => Part('fixed', name, width, [child]);
Part column(String name, List<Part> children) => Part('column', name, null, children);
Part row(String name, List<Part> children) => Part('row', name, null, children);
Part flex(String name, int factor, Part child, {bool tight = true}) =>
    Part('flex', name, null, [child], factor: factor, tightFlex: tight);

class Sizing {
  final String sizing;
  final String path;
  final int width;
  final Limit limit;
  const Sizing(this.sizing, this.path, this.width, this.limit);
}

Sizing classify(String path, int? desire, Limit limit) {
  if (limit.tight) return Sizing('forced', path, limit.min, limit);
  if (desire == null) {
    return limit.max == null
        ? Sizing('clamped', path, limit.min, limit)
        : Sizing('stretched', path, limit.max!, limit);
  }
  if (desire < limit.min) return Sizing('clamped', path, limit.min, limit);
  if (limit.max != null && desire > limit.max!) {
    return Sizing('clamped', path, limit.max!, limit);
  }
  return Sizing('chosen', path, desire, limit);
}

int layout(Part part, Limit limit, List<Sizing> log, [String path = '']) {
  final here = '$path/${part.name}';
  final below = <Sizing>[];
  if (part.kind == 'fixed') {
    final own = classify(here, part.width, limit);
    layout(part.children.first, Limit(own.width, own.width), below, here);
    log..add(own)..addAll(below);
    return own.width;
  }
  int? desire;
  switch (part.kind) {
    case 'leaf':
      desire = part.width;
    case 'pad':
      final space = 2 * part.width!;
      desire = layout(part.children.first, limit.deflate(space), below, here) + space;
    case 'center':
      final inner = layout(part.children.first, limit.loosen(), below, here);
      desire = limit.max == null ? inner : null;
    case 'flex':
      desire = layout(part.children.first, limit, below, here);
    case 'column':
      var widest = 0;
      for (final child in part.children) {
        final width = layout(child, limit.loosen(), below, here);
        if (width > widest) widest = width;
      }
      desire = widest;
    case 'row':
      final logs = [for (final _ in part.children) <Sizing>[]];
      final bounded = limit.max != null;
      var used = 0;
      var factors = 0;
      for (var i = 0; i < part.children.length; i++) {
        final child = part.children[i];
        if (child.kind == 'flex' && bounded) {
          factors += child.factor;
        } else {
          used += layout(child, const Limit(0, null), logs[i], here);
        }
      }
      final left = bounded ? _floor(limit.max! - used) : 0;
      var given = 0;
      var seen = 0;
      for (var i = 0; i < part.children.length; i++) {
        final child = part.children[i];
        if (child.kind != 'flex' || !bounded) continue;
        seen += child.factor;
        final share = (seen == factors ? left : left * seen ~/ factors) - given;
        given += share;
        final room = child.tightFlex ? Limit(share, share) : Limit(0, share);
        used += layout(child, room, logs[i], here);
      }
      for (final entries in logs) {
        below.addAll(entries);
      }
      desire = bounded ? null : used;
  }
  final own = classify(here, desire, limit);
  log..add(own)..addAll(below);
  return own.width;
}

List<Sizing> measure(Part root, int screen) {
  final log = <Sizing>[];
  layout(root, Limit(screen, screen), log);
  return log;
}

void printSizes(List<Sizing> log) {
  for (final entry in log) {
    print('${entry.sizing}: ${entry.path} width=${entry.width} constraint=${entry.limit.bounds()}');
  }
}

void countSizings(List<Sizing> log) {
  for (final name in sizings) {
    print('$name: ${log.where((entry) => entry.sizing == name).length}');
  }
  print('parent: ${log.where((entry) => entry.sizing != 'chosen').length}');
}

The Framework Side

The snippet below shows the configuration. The package name is fictional, and the snippet is not measured.

# Platform code: asset declaration
name: demo_shop
flutter:
  assets:
    - assets/images/logo.png
  fonts:
    - family: Body
      fonts:
        - asset: assets/fonts/body-regular.ttf

assets declares the image files, and fonts declares the font family and its file. The resolution variants stand side by side in a subdirectory such as assets/images/2.0x/logo.png and are not listed separately. After the declaration, the code reads the scale of the font from the user’s setting.

// Platform code: font scale
double labelSize(BuildContext context) {
  final scaler = MediaQuery.textScalerOf(context);
  return scaler.scale(14);
}

textScalerOf gives the scale the user has set, and scale multiplies the base size by that scale. When the text grows, the request of the button that carries the text grows too. The question of this lesson is the place where that growth meets the range.

From Scale to Request

The button of the model carries the width of a label and adds the frame allowance around it. The width of the label is an integer multiplied by the scale: if the base width is 160 units, the label is 160 units wide at scale 1 and 320 units wide at scale 2. The frame allowance is a fixed 24 units. The function that computes the request and the base widths live in a helper file; the helper file prints nothing to the output.

// fonts.dart — label width and the request that depends on the scale
const labelBase = {'Save': 160, 'OK': 80};

int frameWidth(int base, int scale) => base * scale + 24;

buildForm takes the scale and builds the button with this request. The tree is a column inside the screen, and inside the column a title and the button. The first measurement is at scale 1.

// main.dart
import 'sizing.dart';
import 'fonts.dart';

Part buildForm(String label, int scale) {
  return pad('Screen', 16, column('Body', [
    leaf('Title', 120),
    leaf('Button', frameWidth(labelBase[label]!, scale)),
  ]));
}

void main() {
  final log = measure(buildForm('Save', 1), 360);
  printSizes(log);
  countSizings(log);
}
forced: /Screen width=360 constraint=360..360
forced: /Screen/Body width=328 constraint=328..328
chosen: /Screen/Body/Title width=120 constraint=0..328
chosen: /Screen/Body/Button width=184 constraint=0..328
forced: 2
chosen: 2
clamped: 0
stretched: 0
parent: 2

At scale 1 the button asks for 184; because the range is 0..328, the request stays inside the range, and /Screen/Body/Button is chosen. The title is also chosen. The count shows two forced and two chosen; there is no clamped or stretched, and the parent is two.

As the Scale Grows

Now raise the scale from 1 to 3 and compare two labels. Save is a long label; OK is short.

// main.dart
import 'sizing.dart';
import 'fonts.dart';

Part buildForm(String label, int scale) {
  return pad('Screen', 16, column('Body', [
    leaf('Title', 120),
    leaf('Button', frameWidth(labelBase[label]!, scale)),
  ]));
}

void main() {
  for (final label in ['Save', 'OK']) {
    for (final scale in [1, 2, 3]) {
      final log = measure(buildForm(label, scale), 360);
      final button = log.firstWhere((entry) => entry.path == '/Screen/Body/Button');
      print('$label scale=$scale request=${frameWidth(labelBase[label]!, scale)} ${button.sizing} width=${button.width}');
    }
  }
}
Save scale=1 request=184 chosen width=184
Save scale=2 request=344 clamped width=328
Save scale=3 request=504 clamped width=328
OK scale=1 request=104 chosen width=104
OK scale=2 request=184 chosen width=184
OK scale=3 request=264 chosen width=264

Save asks for 184 at scale 1 and stays chosen. At scale 2 the request rises to 344, exceeds 328, and the record becomes clamped; the width is 328. At scale 3 the request is 504, and the record is again clamped, and the width is again 328. OK asks for 104, 184, and 264 at the three scales, and stays chosen in all three. The scale alone does not determine the sizing: the scale enlarges the request, and the relation of the request to the range determines the sizing.

The question if this operation is repeated or its order changes, does the result change looks for the sensitivity of an operation. This lesson does not ask it. The font scale is an input, and it enlarges the request; the sizing classifies not the resistance of the result to repetition, but who fits the growing request into the range.

The Bounding Measurement

At scale 2, Save was clamped. If the range widens, can the same request fit? Measure the same tree at two screen widths: at 360 the range is 0..328, and at 480 it is 0..448. The request stays fixed at 344.

// main.dart
import 'sizing.dart';
import 'fonts.dart';

Part buildForm(String label, int scale) {
  return pad('Screen', 16, column('Body', [
    leaf('Title', 120),
    leaf('Button', frameWidth(labelBase[label]!, scale)),
  ]));
}

void main() {
  for (final screen in [360, 480]) {
    final log = measure(buildForm('Save', 2), screen);
    final button = log.firstWhere((entry) => entry.path == '/Screen/Body/Button');
    print('screen=$screen scale=2 request=344 ${button.sizing} width=${button.width} constraint=${button.limit.bounds()}');
  }
}
screen=360 scale=2 request=344 clamped width=328 constraint=0..328
screen=480 scale=2 request=344 chosen width=344 constraint=0..448

The focus node is /Screen/Body/Button at scale 2. On the screen of 360, the request is 344 and the range is 0..328, so the record is clamped and the width is 328. On the screen of 480, the range becomes 0..448, the request stays inside, and the record is chosen with a width of 344. When the screen or the scale changes, the result can change; what changes the result is not the font scale itself, but whether the growing request fits inside the range.

AS1. The model measures only the width. The wrapping of the label onto a new line, how overflowing text looks, the height of the button, and the real letter measurements of the font are not in the model. The model builds this sizing’s rule; the model is not the framework itself. That the request grows linearly with the scale, and the fixed frame allowance of 24 units, are assumptions of the lesson; the width of a real label comes from the measurements of the font.

The consequence is this: if a button is clamped for a user with a large text setting, the fix is not to suppress the scale. The options go in two directions: reduce the request by shortening the label, or widen the range by moving the button to a place where the range is wide.

Summary

  • This lesson’s sizing: clamped. The focus node is /Screen/Body/Button at scale 2: the request of 344 exceeds the 0..328 range and is pulled to 328.
  • The asset declaration says which files will be packaged; the font scale is read from the user’s setting and enlarges the width of the label.
  • Save came out chosen with 184 at scale 1 and clamped with 344 and 504 at scales 2 and 3; OK stayed chosen at all three scales.
  • When the screen went up to 480, the range became 0..448 and the request of 344 came out chosen again; the width was determined not by the scale but by the relation of the request to the range.
  • The rule sees not the scale itself, but whether the growing request fits.

Next Step

In this lesson the scale enlarged the request, and the result changed between two measurements. The next lesson, Animations, measures the case where the request grows over time in small steps: the tree is measured again on every frame, and on every frame it looks at the owner of the width.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close