flutter / flutter

Flutter makes it easy and fast to build beautiful apps for mobile and beyond
https://flutter.dev
BSD 3-Clause "New" or "Revised" License
162.54k stars 26.72k forks source link

Support configurable placements of SnackBars #52114

Open MarcelGarus opened 4 years ago

MarcelGarus commented 4 years ago

The Material Design guidelines support different placements of SnackBars. Especially on larger screens (like desktop or tablet apps), it makes sense to not force SnackBars to fill the whole width, but rather be left- or center-aligned:

left-aligned SnackBar center-aligned SnackNar

Flutter should support multiple placements of SnackBars.

HansMuller commented 4 years ago

CC @pennzht

tomosullivan8 commented 4 years ago

Ran into this issue today when configuring to a tablet display as it still shows full screen across the bottom, is there any update on this?

eyewritecode commented 4 years ago

@tomosullivan8 Having the same issue as you and it seems like the only alternative is to create a custom SnackBar that overrides SnackBar class but i haven't found my way around this yet

tomosullivan8 commented 4 years ago

@tomosullivan8 Having the same issue as you and it seems like the only alternative is to create a custom SnackBar that overrides SnackBar class but i haven't found my way around this yet

FYI - I had taken the original SnackBar and changed the left padding to reduce the SnackBar to 344.0 (as per Material Specs) if DisplayType.tablet (i've included a generic breakpoint of mediaQuery.width > 700.0). This only changes ifSnackBarBehaviour.Floating, but keeps the SnackBarBehaviour.Fixed to the full length of the display.

You can use the below directly as a SnackBar in the Scaffold.of(context) as it implements a SnackBar.

The below obviously only allows for the alignment of the SnackBar to be on the right hand side (which is only what I needed for now), you can play around with different levels of padding to have the SnackBar forced left, or center. I couldn't get the SnackBar to adjust to BoxConstraints of a Container or SizedBox, it must be overridden to take the full display width within the Scaffold.

I'll just use this until there is an official fix in place, and then I'll revert back.

/// SnackBar to fit [DisplayType.tablet] until fix is pushed through

const double _singleLineVerticalPadding = 14.0;

const Duration _snackBarTransitionDuration = Duration(milliseconds: 250);
const Duration _snackBarDisplayDuration = Duration(milliseconds: 4000);
const Curve _snackBarHeightCurve = Curves.fastOutSlowIn;
const Curve _snackBarFadeInCurve =
    Interval(0.45, 1.0, curve: Curves.fastOutSlowIn);
const Curve _snackBarFadeOutCurve =
    Interval(0.72, 1.0, curve: Curves.fastOutSlowIn);

class SnackBarTest extends StatefulWidget implements SnackBar {
  const SnackBarTest({
    Key key,
    @required this.content,
    this.backgroundColor,
    this.elevation,
    this.shape,
    this.behavior,
    this.action,
    this.duration = _snackBarDisplayDuration,
    this.animation,
    this.onVisible,
  })  : assert(elevation == null || elevation >= 0.0),
        assert(content != null),
        assert(duration != null),
        super(key: key);

  /// The primary content of the snack bar.
  ///
  /// Typically a [Text] widget.
  final Widget content;

  /// The Snackbar's background color. If not specified it will use
  /// [ThemeData.snackBarTheme.backgroundColor]. If that is not specified
  /// it will default to a dark variation of [ColorScheme.surface] for light
  /// themes, or [ColorScheme.onSurface] for dark themes.
  final Color backgroundColor;

  /// The z-coordinate at which to place the snack bar. This controls the size
  /// of the shadow below the snack bar.
  ///
  /// Defines the card's [Material.elevation].
  ///
  /// If this property is null, then [ThemeData.snackBarTheme.elevation] is
  /// used, if that is also null, the default value is 6.0.
  final double elevation;

  /// The shape of the snack bar's [Material].
  ///
  /// Defines the snack bar's [Material.shape].
  ///
  /// If this property is null then [ThemeData.snackBarTheme.shape] is used.
  /// If that's null then the shape will depend on the [SnackBarBehavior]. For
  /// [SnackBarBehavior.fixed], no overriding shape is specified, so the
  /// [SnackBar] is rectangular. For [SnackBarBehavior.floating], it uses a
  /// [RoundedRectangleBorder] with a circular corner radius of 4.0.
  final ShapeBorder shape;

  /// This defines the behavior and location of the snack bar.
  ///
  /// Defines where a [SnackBar] should appear within a [Scaffold] and how its
  /// location should be adjusted when the scaffold also includes a
  /// [FloatingActionButton] or a [BottomNavigationBar]
  ///
  /// If this property is null, then [ThemeData.snackBarTheme.behavior]
  /// is used. If that is null, then the default is [SnackBarBehavior.fixed].
  final SnackBarBehavior behavior;

  /// (optional) An action that the user can take based on the snack bar.
  ///
  /// For example, the snack bar might let the user undo the operation that
  /// prompted the snackbar. Snack bars can have at most one action.
  ///
  /// The action should not be "dismiss" or "cancel".
  final SnackBarAction action;

  /// The amount of time the snack bar should be displayed.
  ///
  /// Defaults to 4.0s.
  ///
  /// See also:
  ///
  ///  * [ScaffoldState.removeCurrentSnackBar], which abruptly hides the
  ///    currently displayed snack bar, if any, and allows the next to be
  ///    displayed.
  ///  * <https://material.io/design/components/snackbars.html>
  final Duration duration;

  /// The animation driving the entrance and exit of the snack bar.
  final Animation<double> animation;

  /// Called the first time that the snackbar is visible within a [Scaffold].
  final VoidCallback onVisible;

  // API for Scaffold.showSnackBar():

  /// Creates an animation controller useful for driving a snack bar's entrance and exit animation.
  static AnimationController createAnimationController(
      {@required TickerProvider vsync}) {
    return AnimationController(
      duration: _snackBarTransitionDuration,
      debugLabel: 'SnackBar',
      vsync: vsync,
    );
  }

  /// Creates a copy of this snack bar but with the animation replaced with the given animation.
  ///
  /// If the original snack bar lacks a key, the newly created snack bar will
  /// use the given fallback key.
  SnackBarTest withAnimation(
    Animation<double> newAnimation, {
    Key fallbackKey,
  }) {
    return SnackBarTest(
      key: key ?? fallbackKey,
      content: content,
      backgroundColor: backgroundColor,
      elevation: elevation,
      shape: shape,
      behavior: behavior,
      action: action,
      duration: duration,
      animation: newAnimation,
      onVisible: onVisible,
    );
  }

  @override
  State<SnackBar> createState() => _SnackBarTestState();
}

class _SnackBarTestState extends State<SnackBar> {
  bool _wasVisible = false;

  @override
  void initState() {
    super.initState();
    widget.animation.addStatusListener(_onAnimationStatusChanged);
  }

  @override
  void didUpdateWidget(SnackBar oldWidget) {
    if (widget.animation != oldWidget.animation) {
      oldWidget.animation.removeStatusListener(_onAnimationStatusChanged);
      widget.animation.addStatusListener(_onAnimationStatusChanged);
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    widget.animation.removeStatusListener(_onAnimationStatusChanged);
    super.dispose();
  }

  void _onAnimationStatusChanged(AnimationStatus animationStatus) {
    switch (animationStatus) {
      case AnimationStatus.dismissed:
      case AnimationStatus.forward:
      case AnimationStatus.reverse:
        break;
      case AnimationStatus.completed:
        if (widget.onVisible != null && !_wasVisible) {
          widget.onVisible();
        }
        _wasVisible = true;
    }
  }

  @override
  Widget build(BuildContext context) {
    final MediaQueryData mediaQueryData = MediaQuery.of(context);
    assert(widget.animation != null);
    final ThemeData theme = Theme.of(context);
    final ColorScheme colorScheme = theme.colorScheme;
    final SnackBarThemeData snackBarTheme = theme.snackBarTheme;
    final bool isThemeDark = theme.brightness == Brightness.dark;

    // SnackBar uses a theme that is the opposite brightness from
    // the surrounding theme.
    final Brightness brightness =
        isThemeDark ? Brightness.light : Brightness.dark;
    final Color themeBackgroundColor = isThemeDark
        ? colorScheme.onSurface
        : Color.alphaBlend(
            colorScheme.onSurface.withOpacity(0.80), colorScheme.surface);
    final ThemeData inverseTheme = ThemeData(
      brightness: brightness,
      backgroundColor: themeBackgroundColor,
      colorScheme: ColorScheme(
        primary: colorScheme.onPrimary,
        primaryVariant: colorScheme.onPrimary,
        // For the button color, the spec says it should be primaryVariant, but for
        // backward compatibility on light themes we are leaving it as secondary.
        secondary:
            isThemeDark ? colorScheme.primaryVariant : colorScheme.secondary,
        secondaryVariant: colorScheme.onSecondary,
        surface: colorScheme.onSurface,
        background: themeBackgroundColor,
        error: colorScheme.onError,
        onPrimary: colorScheme.primary,
        onSecondary: colorScheme.secondary,
        onSurface: colorScheme.surface,
        onBackground: colorScheme.background,
        onError: colorScheme.error,
        brightness: brightness,
      ),
      snackBarTheme: snackBarTheme,
    );

    final TextStyle contentTextStyle =
        snackBarTheme.contentTextStyle ?? inverseTheme.textTheme.subtitle1;
    final SnackBarBehavior snackBarBehavior =
        widget.behavior ?? snackBarTheme.behavior ?? SnackBarBehavior.fixed;
    final bool isFloatingSnackBar =
        snackBarBehavior == SnackBarBehavior.floating;
    final double snackBarPadding = isFloatingSnackBar ? 16.0 : 24.0;

    final CurvedAnimation heightAnimation =
        CurvedAnimation(parent: widget.animation, curve: _snackBarHeightCurve);
    final CurvedAnimation fadeInAnimation =
        CurvedAnimation(parent: widget.animation, curve: _snackBarFadeInCurve);
    final CurvedAnimation fadeOutAnimation = CurvedAnimation(
      parent: widget.animation,
      curve: _snackBarFadeOutCurve,
      reverseCurve: const Threshold(0.0),
    );

    Widget snackBar = SafeArea(
      top: false,
      left: true,
      right: true,
      bottom: !isFloatingSnackBar,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          SizedBox(width: snackBarPadding),
          Expanded(
            child: Container(
              padding: const EdgeInsets.symmetric(
                  vertical: _singleLineVerticalPadding),
              child: DefaultTextStyle(
                style: contentTextStyle,
                child: widget.content,
              ),
            ),
          ),
          if (widget.action != null)
            ButtonTheme(
              textTheme: ButtonTextTheme.accent,
              minWidth: 64.0,
              padding: EdgeInsets.symmetric(horizontal: snackBarPadding),
              child: widget.action,
            )
          else
            SizedBox(width: snackBarPadding),
        ],
      ),
    );

    final double elevation = widget.elevation ?? snackBarTheme.elevation ?? 6.0;
    final Color backgroundColor = widget.backgroundColor ??
        snackBarTheme.backgroundColor ??
        inverseTheme.backgroundColor;
    final ShapeBorder shape = widget.shape ??
        snackBarTheme.shape ??
        (isFloatingSnackBar
            ? RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0))
            : null);

    snackBar = Material(
      shape: shape,
      elevation: elevation,
      color: backgroundColor,
      child: Theme(
        data: inverseTheme,
        child: mediaQueryData.accessibleNavigation
            ? snackBar
            : FadeTransition(
                opacity: fadeOutAnimation,
                child: snackBar,
              ),
      ),
    );

    double tableBreakpoint = 700.0;
    double leftPadding() {
      if (mediaQueryData.size.width > tableBreakpoint) {
        return mediaQueryData.size.width - 344.0 - 15.0;
      } else {
        return 15.0;
      }
    }

    if (isFloatingSnackBar) {
      snackBar = Padding(
        padding: EdgeInsets.fromLTRB(
          leftPadding(),
          5.0,
          15.0,
          mediaQueryData.padding.bottom + 10.0,
        ),
        child: snackBar,
      );
    }

    snackBar = Semantics(
      container: true,
      liveRegion: true,
      onDismiss: () {
        Scaffold.of(context)
            .removeCurrentSnackBar(reason: SnackBarClosedReason.dismiss);
      },
      child: Dismissible(
        key: const Key('dismissible'),
        direction: DismissDirection.down,
        resizeDuration: null,
        onDismissed: (DismissDirection direction) {
          Scaffold.of(context)
              .removeCurrentSnackBar(reason: SnackBarClosedReason.swipe);
        },
        child: snackBar,
      ),
    );

    Widget snackBarTransition;
    if (mediaQueryData.accessibleNavigation) {
      snackBarTransition = snackBar;
    } else if (isFloatingSnackBar) {
      snackBarTransition = FadeTransition(
        opacity: fadeInAnimation,
        child: snackBar,
      );
    } else {
      snackBarTransition = AnimatedBuilder(
        animation: heightAnimation,
        builder: (BuildContext context, Widget child) {
          return Align(
            alignment: AlignmentDirectional.topStart,
            heightFactor: heightAnimation.value,
            child: child,
          );
        },
        child: snackBar,
      );
    }

    return ClipRect(
      child: snackBarTransition,
    );
  }
}
fredgrott commented 2 years ago

This would be an ideal use of adaptive breakpoints package would it not?

k-ane commented 1 year ago

Any update on this? the center aligned style on desktop apps doesn't look great