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

ui.ImageFilter producing different results on Android and iOS - Flutter Bug or Error in my Code? #145891

Open
xonaman opened this issue Mar 28, 2024 · 4 comments
Labels
in triage Presently being triaged by the triage team waiting for customer response The Flutter team cannot make further progress on this issue until the original reporter responds

Comments

@xonaman
Copy link

xonaman commented Mar 28, 2024

Steps to reproduce

I have implemented a custom button widget that blurs its child when being pressed:

class CustomButton extends StatefulWidget {
  final Widget child;
  final ValueChanged<Offset>? onTap;
  final ValueChanged<Offset>? onLongTap;
  final GestureTapDownCallback? onTapDown;
  final BorderRadiusGeometry borderRadius;

  const CustomButton({
    super.key,
    required this.child,
    required this.onTap,
    this.onLongTap,
    this.onTapDown,
    this.borderRadius = BorderRadius.zero,
  });

  @override
  _ButtonState createState() => _ButtonState();
}

class _ButtonState extends State<CustomButton> with SingleTickerProviderStateMixin {
  late final AnimationController _animation;
  late Duration _animationDuration;
  late Curve _animationCurve;
  Offset _tapPosition = Offset.zero;

  @override
  void initState() {
    super.initState();
    _animation = AnimationController(vsync: this);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final ThemeData theme = Theme.of(context);
    _animationDuration = theme.animationDuration;
    _animationCurve = theme.animationCurve;
  }

  @override
  void dispose() {
    _animation.dispose();
    super.dispose();
  }

  void _onHoverStart(TapDownDetails details) {
    _tapPosition = details.globalPosition;
    widget.onTapDown?.call(details);
    if (widget.onTap != null || widget.onLongTap != null) {
      _animation.animateTo(
        1.0,
        duration: _animationDuration,
        curve: _animationCurve,
      );
    }
  }

  void _onHoverEnd() {
    if (widget.onTap == null && widget.onLongTap == null) {
      return;
    }
    if (_animation.value < 1.0) {
      final TickerFuture ticker = _animation.animateTo(
        1.0,
        duration: _animationDuration * 0.5,
        curve: _animationCurve,
      );
      ticker.whenComplete(() {
        _animation.animateBack(
          0.0,
          duration: _animationDuration * 0.5,
          curve: _animationCurve,
        );
      });
    } else if (_animation.value > 0.0) {
      _animation.animateBack(
        0.0,
        duration: _animationDuration,
        curve: _animationCurve,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: widget.onTap != null
          ? () => HapticFeedback.lightImpact()
              .whenComplete(() => widget.onTap!(_tapPosition))
          : null,
      onLongPress: widget.onLongTap != null
          ? () => HapticFeedback.lightImpact()
              .whenComplete(() => widget.onLongTap!(_tapPosition))
          : null,
      onTapDown: _onHoverStart,
      onTapUp: (_) => _onHoverEnd(),
      onTapCancel: () => _onHoverEnd(),
      child: Container(
        color: Colors.transparent,
        child: AnimatedBuilder(
          animation: _animation,
          child: widget.child,
          builder: (_, Widget? child) {
            final double blurSigma = Tween<double>(
              begin: 0.0,
              end: 1.0,
            ).evaluate(_animation);
            return Opacity(
              opacity: Tween<double>(
                begin: 1.0,
                end: 0.5,
              ).evaluate(_animation),
              child: ImageFiltered(
                imageFilter: ui.ImageFilter.blur(
                  sigmaX: blurSigma,
                  sigmaY: blurSigma,
                ),
                child: child!,
              ),
            );
          },
        ),
      ),
    );
  }
}

Expected results

On iOS, the button looks as expected when pressing and holding the button (except on the very left and the very right side):
ios

Actual results

On Android (Emulator) however, it looks as follows:
android

Code sample

Code sample
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: Center(
        child: CustomButton(
          onTap: (_) {},
          child: Container(
            color: Colors.red,
            width: 120.0,
            height: 120.0,
            child: const Center(
              child: Text('Test'),
            ),
          ),
        ),
      ),
    );
  }
}

class CustomButton extends StatefulWidget {
  final Widget child;
  final ValueChanged<Offset>? onTap;
  final ValueChanged<Offset>? onLongTap;
  final GestureTapDownCallback? onTapDown;
  final BorderRadiusGeometry borderRadius;

  const CustomButton({
    super.key,
    required this.child,
    required this.onTap,
    this.onLongTap,
    this.onTapDown,
    this.borderRadius = BorderRadius.zero,
  });

  @override
  _ButtonState createState() => _ButtonState();
}

class _ButtonState extends State<CustomButton>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animation;
  late Duration _animationDuration;
  late Curve _animationCurve;
  Offset _tapPosition = Offset.zero;

  @override
  void initState() {
    super.initState();
    _animation = AnimationController(vsync: this);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final ThemeData theme = Theme.of(context);
    _animationDuration = theme.animationDuration;
    _animationCurve = theme.animationCurve;
  }

  @override
  void dispose() {
    _animation.dispose();
    super.dispose();
  }

  void _onHoverStart(TapDownDetails details) {
    _tapPosition = details.globalPosition;
    widget.onTapDown?.call(details);
    if (widget.onTap != null || widget.onLongTap != null) {
      _animation.animateTo(
        1.0,
        duration: _animationDuration,
        curve: _animationCurve,
      );
    }
  }

  void _onHoverEnd() {
    if (widget.onTap == null && widget.onLongTap == null) {
      return;
    }
    if (_animation.value < 1.0) {
      final TickerFuture ticker = _animation.animateTo(
        1.0,
        duration: _animationDuration * 0.5,
        curve: _animationCurve,
      );
      ticker.whenComplete(() {
        _animation.animateBack(
          0.0,
          duration: _animationDuration * 0.5,
          curve: _animationCurve,
        );
      });
    } else if (_animation.value > 0.0) {
      _animation.animateBack(
        0.0,
        duration: _animationDuration,
        curve: _animationCurve,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: widget.onTap != null
          ? () => HapticFeedback.lightImpact()
              .whenComplete(() => widget.onTap!(_tapPosition))
          : null,
      onLongPress: widget.onLongTap != null
          ? () => HapticFeedback.lightImpact()
              .whenComplete(() => widget.onLongTap!(_tapPosition))
          : null,
      onTapDown: _onHoverStart,
      onTapUp: (_) => _onHoverEnd(),
      onTapCancel: () => _onHoverEnd(),
      child: Container(
        color: Colors.transparent,
        child: AnimatedBuilder(
          animation: _animation,
          child: widget.child,
          builder: (_, Widget? child) {
            final double blurSigma = Tween<double>(
              begin: 0.0,
              end: 1.0,
            ).evaluate(_animation);
            return Opacity(
              opacity: Tween<double>(
                begin: 1.0,
                end: 0.5,
              ).evaluate(_animation),
              child: ImageFiltered(
                imageFilter: ui.ImageFilter.blur(
                  sigmaX: blurSigma,
                  sigmaY: blurSigma,
                ),
                child: child!,
              ),
            );
          },
        ),
      ),
    );
  }
}

extension on ThemeData {
  Duration get animationDuration => const Duration(milliseconds: 300);

  Curve get animationCurve => Curves.linearToEaseOut;
}

Screenshots or Video

Screenshots / Video demonstration
Screen_recording_20240328_132901.mov

Logs

Logs
  • does not apply -

Flutter Doctor output

Doctor output
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.19.4, on macOS 14.4 23E214 darwin-arm64, locale en-US)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.3)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.2)
[✓] VS Code (version 1.87.2)
[✓] Connected device (2 available)
[✓] Network resources

• No issues found!
@huycozy huycozy added the in triage Presently being triaged by the triage team label Mar 29, 2024
@huycozy
Copy link
Member

huycozy commented Mar 29, 2024

Hi @xonaman
Could you share your demo corresponding to the sample code you shared? I checked it on my Android device, on Flutter stable 3.19.5 but couldn't see the issue:

Screen.Recording.2024-03-29.at.13.12.45.mov

Also, do you enable Impeller on Android?

@huycozy huycozy added the waiting for customer response The Flutter team cannot make further progress on this issue until the original reporter responds label Mar 29, 2024
Copy link

Without additional information, we are unfortunately not sure how to resolve this issue. We are therefore reluctantly going to close this bug for now.
If you find this problem please file a new issue with the same description, what happens, logs and the output of 'flutter doctor -v'. All system setups can be slightly different so it's always better to open new issues and reference the related ones.
Thanks for your contribution.

@xonaman
Copy link
Author

xonaman commented Apr 23, 2024

@huycozy no, I have not enabled impeller. Were you running your test with impeller enabled?

Also, I’m testing on an android simulator only, I don't have a real device to test with unfortunately.

I have shared my demo in the initial message under "Code sample". I am working for a company, so I have to be careful with sharing code.

@github-actions github-actions bot removed the waiting for customer response The Flutter team cannot make further progress on this issue until the original reporter responds label Apr 23, 2024
@github-actions github-actions bot reopened this Apr 23, 2024
@huycozy
Copy link
Member

huycozy commented Apr 24, 2024

Were you running your test with impeller enabled?

No, I didn't enable Impeller. I also checked this again on Android simulator but couldn't see the issue.

so I have to be careful with sharing code.

Yes, please don't share your project code because it's sensitive and not easy to find the related component causing the issue.

So, maybe the sample code above is not enough to demonstrate issue, could you update it?

@huycozy huycozy added the waiting for customer response The Flutter team cannot make further progress on this issue until the original reporter responds label Apr 24, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in triage Presently being triaged by the triage team waiting for customer response The Flutter team cannot make further progress on this issue until the original reporter responds
Projects
None yet
Development

No branches or pull requests

2 participants