Skip to content

[Impeller] Render issue with custom border #155075

@zhxst

Description

@zhxst

Steps to reproduce

  1. Run the sample code below.
  2. Tap those TextField one by one.

I have modified InputDecorationTheme with a custom Border.
The custom AbovelineInputBorder is copy and modified from UnderlineInputBorder,
Which draw both underline and aboveline border for TextField.

It looks good in Skia and Flutter 3.22.3
However the effect become broken in 3.24.2

Expected results

The custom border draws both aboveline and underline.

Actual results

There are some extra underlines on the screen.
Some time they even glitch when you tap TextForms one by one.

Code sample

Code sample
import 'dart:math' as math;
import 'package:flutter/material.dart';

void main() {
  final colorScheme = ColorScheme.fromSeed(seedColor: Colors.blue);
  runApp(MyApp(
    colorScheme: colorScheme,
  ));
}

class MyApp extends StatelessWidget {
  final ColorScheme colorScheme;
  const MyApp({super.key, required this.colorScheme});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.light().copyWith(
        colorScheme: colorScheme,
        inputDecorationTheme: InputDecorationTheme(
          filled: true,
          // fillColor: Color.lerp(
          //     colorScheme.primaryFixed, colorScheme.surfaceContainerHighest, 0.7),
          border: AbovelineInputBorder(
            borderRadius: BorderRadius.circular(8),
          ),
          enabledBorder: AbovelineInputBorder(
            borderRadius: BorderRadius.circular(8),
            borderSide: BorderSide(color: colorScheme.primaryContainer),
          ),
          focusedBorder: AbovelineInputBorder(
            borderRadius: BorderRadius.circular(8),
            borderSide: BorderSide(color: colorScheme.primary),
          ),
          focusColor: colorScheme.primaryContainer,
        ),
      ),
      home: const HomeScreen(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Padding(
          padding: EdgeInsets.symmetric(horizontal: 20),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextField(),
              SizedBox(height: 20),
              TextField(),
              SizedBox(height: 20),
              TextField(),
              SizedBox(height: 20),
            ],
          ),
        ),
      ),
    );
  }
}

/// Copy and Modified from [UnderlineInputBorder].

/// Draws a horizontal line at the top of an [InputDecorator]'s container and
/// defines the container's shape.
///
/// The input decorator's "container" is the optionally filled area above the
/// decorator's helper, error, and counter.
///
/// See also:
///
///  * [OutlineInputBorder], an [InputDecorator] border which draws a
///    rounded rectangle around the input decorator's container.
///  * [InputDecoration], which is used to configure an [InputDecorator].
class AbovelineInputBorder extends InputBorder {
  /// Creates an underline border for an [InputDecorator].
  ///
  /// The [borderSide] parameter defaults to [BorderSide.none] (it must not be
  /// null). Applications typically do not specify a [borderSide] parameter
  /// because the input decorator substitutes its own, using [copyWith], based
  /// on the current theme and [InputDecorator.isFocused].
  ///
  /// The [borderRadius] parameter defaults to a value where the top left
  /// and right corners have a circular radius of 4.0.
  const AbovelineInputBorder({
    super.borderSide = const BorderSide(),
    this.borderRadius = const BorderRadius.only(
      topLeft: Radius.circular(4.0),
      topRight: Radius.circular(4.0),
    ),
  });

  /// The radii of the border's rounded rectangle corners.
  ///
  /// When this border is used with a filled input decorator, see
  /// [InputDecoration.filled], the border radius defines the shape
  /// of the background fill as well as the top left and right
  /// edges of the underline itself.
  ///
  /// By default the top right and top left corners have a circular radius
  /// of 4.0.
  final BorderRadius borderRadius;

  @override
  bool get isOutline => false;

  @override
  AbovelineInputBorder copyWith(
      {BorderSide? borderSide, BorderRadius? borderRadius}) {
    return AbovelineInputBorder(
      borderSide: borderSide ?? this.borderSide,
      borderRadius: borderRadius ?? this.borderRadius,
    );
  }

  @override
  EdgeInsetsGeometry get dimensions {
    return EdgeInsets.symmetric(vertical: borderSide.width);
  }

  @override
  AbovelineInputBorder scale(double t) {
    return AbovelineInputBorder(borderSide: borderSide.scale(t));
  }

  @override
  Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
    return Path()
      ..addRect(Rect.fromLTWH(rect.left, math.max(0.0, borderSide.width),
          rect.width, rect.top - 2 * math.max(0.0, borderSide.width)));
  }

  @override
  Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
    return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect));
  }

  @override
  void paintInterior(Canvas canvas, Rect rect, Paint paint,
      {TextDirection? textDirection}) {
    canvas.drawRRect(borderRadius.resolve(textDirection).toRRect(rect), paint);
  }

  @override
  bool get preferPaintInterior => true;

  @override
  ShapeBorder? lerpFrom(ShapeBorder? a, double t) {
    if (a is AbovelineInputBorder) {
      return AbovelineInputBorder(
        borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
        borderRadius: BorderRadius.lerp(a.borderRadius, borderRadius, t)!,
      );
    }
    return super.lerpFrom(a, t);
  }

  @override
  ShapeBorder? lerpTo(ShapeBorder? b, double t) {
    if (b is AbovelineInputBorder) {
      return AbovelineInputBorder(
        borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
        borderRadius: BorderRadius.lerp(borderRadius, b.borderRadius, t)!,
      );
    }
    return super.lerpTo(b, t);
  }

  /// Draw a horizontal line at the top of [rect].
  ///
  /// The [borderSide] defines the line's color and weight. The `textDirection`
  /// `gap` and `textDirection` parameters are ignored.
  @override
  void paint(
    Canvas canvas,
    Rect rect, {
    double? gapStart,
    double gapExtent = 0.0,
    double gapPercentage = 0.0,
    TextDirection? textDirection,
  }) {
    if (borderSide.style == BorderStyle.none) {
      return;
    }

    if (borderRadius.topLeft != Radius.zero ||
        borderRadius.topRight != Radius.zero ||
        borderRadius.bottomLeft != Radius.zero ||
        borderRadius.bottomRight != Radius.zero) {
      // This prevents the border from leaking the color due to anti-aliasing rounding errors.
      final BorderRadius updatedBorderRadius = BorderRadius.only(
        topLeft: borderRadius.topLeft
            .clamp(maximum: Radius.circular(rect.height / 2)),
        topRight: borderRadius.topRight
            .clamp(maximum: Radius.circular(rect.height / 2)),
        bottomLeft: borderRadius.bottomLeft
            .clamp(maximum: Radius.circular(rect.height / 2)),
        bottomRight: borderRadius.bottomRight
            .clamp(maximum: Radius.circular(rect.height / 2)),
      );

      // We set the strokeAlign to center, so the behavior is consistent with
      // drawLine and with the historical behavior of this border.
      paintNonUniformBorder(
        canvas,
        rect,
        textDirection: textDirection,
        borderRadius: updatedBorderRadius,
        top: borderSide.copyWith(strokeAlign: BorderSide.strokeAlignCenter),
        color: borderSide.color,
        maskFilter: const MaskFilter.blur(BlurStyle.inner, 1),
      );
      paintNonUniformBorder(
        canvas,
        rect,
        textDirection: textDirection,
        borderRadius: updatedBorderRadius,
        bottom: borderSide.copyWith(strokeAlign: BorderSide.strokeAlignCenter),
        color: borderSide.color,
        maskFilter: const MaskFilter.blur(BlurStyle.solid, 0.5),
      );
    } else {
      canvas.drawLine(
          rect.topLeft,
          rect.topRight,
          borderSide.toPaint()
            ..maskFilter = const MaskFilter.blur(BlurStyle.inner, 1));
      canvas.drawLine(
          rect.bottomLeft,
          rect.bottomRight,
          borderSide.toPaint()
            ..maskFilter = const MaskFilter.blur(BlurStyle.inner, 1));
    }
  }

  static void paintNonUniformBorder(
    Canvas canvas,
    Rect rect, {
    required BorderRadius? borderRadius,
    required TextDirection? textDirection,
    BoxShape shape = BoxShape.rectangle,
    BorderSide top = BorderSide.none,
    BorderSide right = BorderSide.none,
    BorderSide bottom = BorderSide.none,
    BorderSide left = BorderSide.none,
    required Color color,
    MaskFilter? maskFilter,
  }) {
    final RRect borderRect;
    switch (shape) {
      case BoxShape.rectangle:
        borderRect = (borderRadius ?? BorderRadius.zero)
            .resolve(textDirection)
            .toRRect(rect);
      case BoxShape.circle:
        assert(borderRadius == null,
            'A borderRadius cannot be given when shape is a BoxShape.circle.');
        borderRect = RRect.fromRectAndRadius(
          Rect.fromCircle(center: rect.center, radius: rect.shortestSide / 2.0),
          Radius.circular(rect.width),
        );
    }
    final Paint paint = Paint()
      ..color = color
      ..maskFilter = maskFilter;
    final RRect inner = _deflateRRect(
        borderRect,
        EdgeInsets.fromLTRB(left.strokeInset, top.strokeInset,
            right.strokeInset, bottom.strokeInset));
    final RRect outer = _inflateRRect(
        borderRect,
        EdgeInsets.fromLTRB(left.strokeOutset, top.strokeOutset,
            right.strokeOutset, bottom.strokeOutset));
    canvas.drawDRRect(outer, inner, paint);
  }

  static RRect _inflateRRect(RRect rect, EdgeInsets insets) {
    return RRect.fromLTRBAndCorners(
      rect.left - insets.left,
      rect.top - insets.top,
      rect.right + insets.right,
      rect.bottom + insets.bottom,
      topLeft: (rect.tlRadius + Radius.elliptical(insets.left, insets.top))
          .clamp(minimum: Radius.zero),
      topRight: (rect.trRadius + Radius.elliptical(insets.right, insets.top))
          .clamp(minimum: Radius.zero),
      bottomRight:
          (rect.brRadius + Radius.elliptical(insets.right, insets.bottom))
              .clamp(minimum: Radius.zero),
      bottomLeft:
          (rect.blRadius + Radius.elliptical(insets.left, insets.bottom))
              .clamp(minimum: Radius.zero),
    );
  }

  static RRect _deflateRRect(RRect rect, EdgeInsets insets) {
    return RRect.fromLTRBAndCorners(
      rect.left + insets.left,
      rect.top + insets.top,
      rect.right - insets.right,
      rect.bottom - insets.bottom,
      topLeft: (rect.tlRadius - Radius.elliptical(insets.left, insets.top))
          .clamp(minimum: Radius.zero),
      topRight: (rect.trRadius - Radius.elliptical(insets.right, insets.top))
          .clamp(minimum: Radius.zero),
      bottomRight:
          (rect.brRadius - Radius.elliptical(insets.right, insets.bottom))
              .clamp(minimum: Radius.zero),
      bottomLeft:
          (rect.blRadius - Radius.elliptical(insets.left, insets.bottom))
              .clamp(minimum: Radius.zero),
    );
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) {
      return true;
    }
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is AbovelineInputBorder &&
        other.borderSide == borderSide &&
        other.borderRadius == borderRadius;
  }

  @override
  int get hashCode => Object.hash(borderSide, borderRadius);
}

Screenshots or Video

Screenshots / Video demonstration
3.24.2 Android 3.22.3 iOS 3.24.2 iOS
Screenshot_123  IMG_7184   IMG_7183

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.24.2, on macOS 13.6 22G120 darwin-arm64, locale zh-Hans-CN)
    • Flutter version 3.24.2 on channel stable at /Users/zhx/fvm/versions/stable
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 4cf269e36d (9 days ago), 2024-09-03 14:30:00 -0700
    • Engine revision a6bd3f1de1
    • Dart version 3.5.2
    • DevTools version 2.37.2
    • Pub download mirror https://pub.flutter-io.cn
    • Flutter download mirror https://storage.flutter-io.cn

[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
    • Android SDK at /Users/zhx/Library/Android/sdk
    • Platform android-34, build-tools 34.0.0
    • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 17.0.11+0-17.0.11b1207.24-11852314)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 15C500b
    • CocoaPods version 1.15.2

[✓] Android Studio (version 2024.1)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 17.0.11+0-17.0.11b1207.24-11852314)

[✓] VS Code (version 1.93.0)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.97.20240902

[!] Proxy Configuration
    • HTTP_PROXY is set
    ! NO_PROXY is not set

[✓] Connected device (3 available)
    • iPhone (mobile)                    • ************** • ios          • iOS 17.5.1 21F90
    • macOS (desktop)                 • macos                     • darwin-arm64 • macOS 13.6 22G120 darwin-arm64
    • Mac Designed for iPad (desktop) • mac-designed-for-ipad     • darwin       • macOS 13.6 22G120 darwin-arm64

[✓] Network resources
    • All expected network resources are available.

Metadata

Metadata

Assignees

Labels

P1High-priority issues at the top of the work listc: regressionIt was better in the past than it is nowc: renderingUI glitches reported at the engine/skia or impeller rendering levele: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.found in release: 3.24Found to occur in 3.24found in release: 3.26Found to occur in 3.26has reproducible stepsThe issue has been confirmed reproducible and is ready to work onr: fixedIssue is closed as already fixed in a newer versionteam-engineOwned by Engine team

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions