Skip to content

[Impeller] Scaled text is lower resolution and has more jitter than Skia #165583

@yakagami

Description

@yakagami

Steps to reproduce

  1. Run the following code with Impeller:
code
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.blue,
      ),
      home: const ZoomViewMvp(),
    );
  }
}

class ZoomViewMvp extends StatefulWidget {
  const ZoomViewMvp({super.key});

  @override
  State<ZoomViewMvp> createState() => _ZoomViewMvpState();
}

class _ZoomViewMvpState extends State<ZoomViewMvp> {

  final controller = ScrollController();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ZoomView(
        controller: controller,
        child: ListView.builder(
          controller: controller,
          itemBuilder: (context, index) {
            return Center(child: Text("simple text ${index.toString()}"));
          },
        )
      ),
    );
  }
}


///Allows a ListView or other Scrollables that implement ScrollPosition and
///jumpTo(offset) in their controller to be zoomed and scrolled.
class ZoomView extends StatefulWidget {
  const ZoomView({
    super.key,
    required this.child,
    required this.controller,
    this.maxScale = 4.0,
    this.minScale = 1.0,
    this.scrollAxis = Axis.vertical,
  });

  final Widget child;
  final ScrollController controller;

  ///scrollAxis must be set to Axis.horizontal if the Scrollable is horizontal
  final Axis scrollAxis;

  ///The maximum scale that the ZoomView can be zoomed to. Set to double.infinity to allow infinite zoom in
  final double maxScale;

  ///The minimum scale that the ZoomView can be zoomed to. Set to 0 to allow infinite zoom out
  final double minScale;

  @override
  State<ZoomView> createState() => _ZoomViewState();
}

class _ZoomViewState extends State<ZoomView> with TickerProviderStateMixin {
  @override
  void initState() {
    if (widget.scrollAxis == Axis.vertical) {
      _verticalController = widget.controller;
      _horizontalController = ScrollController();
    } else {
      _verticalController = ScrollController();
      _horizontalController = widget.controller;
    }

    super.initState();
  }

  ///The current scale of the ZoomView
  double _scale = 1;

  ///The scale of the ZoomView before the last scale update event
  double _lastScale = 1;

  late final ScrollController _verticalController;
  late final ScrollController _horizontalController;

  ///The focal point of pointers at the start of a scale event
  late Offset _localFocalPoint;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        double height = constraints.maxHeight;
        double width = constraints.maxWidth;
        return GestureDetector(
          behavior: HitTestBehavior.translucent,
          onScaleStart: (ScaleStartDetails details) {
            if (details.pointerCount != 1) {
              _localFocalPoint = details.localFocalPoint;
            }
          },
          onScaleUpdate: (ScaleUpdateDetails details) {
            if (details.pointerCount > 1) {
              final newScale = _lastScale / details.scale;
              final verticalOffset =
                  _verticalController.position.pixels + (_scale - newScale) * _localFocalPoint.dy;
              final horizontalOffset = _horizontalController.position.pixels +
                  (_scale - newScale) * _localFocalPoint.dx;
              //This is the main logic to actually perform the scaling
              setState(() {
                _scale = newScale;
              });
              _verticalController.jumpTo(verticalOffset);
              _horizontalController.jumpTo(horizontalOffset);
            } else {
              final correctedDelta = details.focalPointDelta * _scale;
              _verticalController.jumpTo(_verticalController.position.pixels - correctedDelta.dy);
              _horizontalController.jumpTo(_horizontalController.position.pixels - correctedDelta.dx);
            }
          },
          onScaleEnd: (ScaleEndDetails details) {
            _lastScale = _scale;
          },
          child: Column(
            children: [
              Expanded(
                //When scale decreases, the SizedBox will shrink and the FittedBox
                //will scale the child to fit the maximum constraints of the ZoomView
                child: FittedBox(
                  fit: BoxFit.fill,
                  child: SizedBox(
                    height: height * _scale,
                    width: width * _scale,
                    child: Center(
                      child: ScrollConfiguration(
                        behavior: const ScrollBehavior().copyWith(
                          overscroll: false,
                          //Disable all inputs on the list as we will handle them
                          //ourselves using the gesture detector and scroll controllers
                          dragDevices: <PointerDeviceKind>{},
                          scrollbars: false,
                        ),
                        child: ListView(
                          physics: const ClampingScrollPhysics(),
                          controller: widget.scrollAxis == Axis.vertical
                              ? _horizontalController
                              : _verticalController,
                          scrollDirection: widget.scrollAxis == Axis.vertical
                              ? Axis.horizontal
                              : Axis.vertical,
                          children: [
                            SizedBox(
                            width: widget.scrollAxis == Axis.vertical ? width : null,
                            height: widget.scrollAxis == Axis.vertical ? null : height,
                            child: widget.child,
                          ),
                          ],
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}
2. Zoom into the letters 3. Observe that the text jitters and is jagged

Expected results

Text is not jittery and is smooth like skia

Actual results

the text jitters and is jagged

Code sample

See above

Screenshots or Video

Screenshots / Video demonstration

Impeller:

screen-20250320-134229.mp4

Skia:

screen-20250320-134348.mp4

I should note skia looked even smoother on device. I think the video encoding might be making it look more jagged there.

another example
screen-20250320-140112.mp4

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[✓] Flutter (Channel master, 3.31.0-1.0.pre.155)
• No issues found!

Metadata

Metadata

Assignees

Labels

P1High-priority issues at the top of the work liste: impellerImpeller rendering backend issues and features requeststeam-engineOwned by Engine teamtriaged-engineTriaged by Engine team

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions