bubble_label 5.1.0 copy "bubble_label: ^5.1.0" to clipboard
bubble_label: ^5.1.0 copied to clipboard

A small Flutter package that shows a floating bubble label anchored to a child widget, and optional background overlay + simple show/dismiss animations.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:bubble_label/bubble_label.dart';
import 'package:s_toggle/s_toggle.dart';
import 'package:flutter_web_frame/flutter_web_frame.dart';

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

/// Example application used in this package's `example` folder.
///
/// Demonstrates typical usage of the `BubbleLabel` API.
class ExampleApp extends StatefulWidget {
  /// Creates the example application.
  const ExampleApp({super.key});

  @override
  State<ExampleApp> createState() => _ExampleAppState();
}

class _ExampleAppState extends State<ExampleApp> {
  /// Whether the bubble overlay should ignore pointer events.
  ///
  /// When true (default), the overlay will ignore pointer events so the
  /// underlying widgets remain interactive.
  bool shouldIgnorePointer = true;

  /// Whether to animate show/dismiss operations in the example app.
  bool animate = true;

  /// Toggle to enable a background overlay behind the bubble.
  bool useOverlay = true;

  /// Toggle to wrap the content in a Transform.scale widget.
  /// This demonstrates that bubbles position correctly even with transforms.
  bool useTransform = false;

  /// Toggle to use ForcePhoneSizeOnWeb wrapper.
  /// This simulates the common use case of wrapping web apps for phone dimensions.
  bool useForcePhoneSize = false;

  /// The scale factor when transform is enabled.
  double transformScale = 0.45;

  /// Visual feedback message for tap inside/outside detection
  String? _tapFeedback;

  void _showTapFeedback(String message, Color color) {
    setState(() => _tapFeedback = message);
    // Auto-clear after 1.5 seconds
    Future.delayed(const Duration(milliseconds: 1500), () {
      if (mounted) setState(() => _tapFeedback = null);
    });
  }

  /// Builds the example page, optionally wrapped with Transform.scale
  /// or ForcePhoneSizeOnWeb to demonstrate that bubbles position correctly
  /// even with transforms.
  Widget _buildExamplePageWithOptionalTransform() {
    final examplePage = ExamplePage(
      animate: animate,
      useOverlay: useOverlay,
      shouldIgnorePointer: shouldIgnorePointer,
      onTapFeedback: _showTapFeedback,
    );

    // No transform wrappers
    if (!useTransform && !useForcePhoneSize) {
      return examplePage;
    }

    // ForcePhoneSizeOnWeb wrapper (simulates flutter_web_frame behavior)
    if (useForcePhoneSize) {
      return Container(
        decoration: BoxDecoration(
          border: Border.all(color: Colors.blue.shade300, width: 2),
          borderRadius: BorderRadius.circular(8),
        ),
        margin: const EdgeInsets.symmetric(horizontal: 16),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(6),
          child: Stack(
            children: [
              // Background label showing this is transformed
              Positioned(
                top: 4,
                right: 4,
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
                  decoration: BoxDecoration(
                    color: Colors.blue.shade100,
                    borderRadius: BorderRadius.circular(4),
                  ),
                  child: Text(
                    'ForcePhoneSizeOnWeb',
                    style: TextStyle(
                      fontSize: 10,
                      color: Colors.blue.shade800,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ),
              // The ForcePhoneSizeOnWeb wrapper (using FlutterWebFrame)
              Center(
                child: FlutterWebFrame(
                  maximumSize: const Size(350, 600),
                  enabled: true,
                  backgroundColor: Colors.grey.shade200,
                  builder: (context) => examplePage,
                ),
              ),
            ],
          ),
        ),
      );
    }

    // Transform.scale wrapper
    return Container(
      decoration: BoxDecoration(
        border: Border.all(color: Colors.purple.shade300, width: 2),
        borderRadius: BorderRadius.circular(8),
      ),
      margin: const EdgeInsets.symmetric(horizontal: 16),
      child: ClipRRect(
        borderRadius: BorderRadius.circular(6),
        child: Stack(
          children: [
            // Background label showing this is transformed
            Positioned(
              top: 4,
              right: 4,
              child: Container(
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
                decoration: BoxDecoration(
                  color: Colors.purple.shade100,
                  borderRadius: BorderRadius.circular(4),
                ),
                child: Text(
                  'Transform.scale(${transformScale.toStringAsFixed(2)})',
                  style: TextStyle(
                    fontSize: 10,
                    color: Colors.purple.shade800,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
            // The scaled content
            Center(
              child: Transform.scale(
                scale: transformScale,
                child: examplePage,
              ),
            ),
          ],
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Bubble Label Example',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Bubble Label Example'),
          // Show tap feedback in the app bar
          bottom: _tapFeedback != null
              ? PreferredSize(
                  preferredSize: const Size.fromHeight(30),
                  child: Container(
                    width: double.infinity,
                    padding: const EdgeInsets.symmetric(vertical: 6),
                    color: _tapFeedback!.contains('INSIDE')
                        ? Colors.green.shade100
                        : Colors.orange.shade100,
                    child: Text(
                      _tapFeedback!,
                      textAlign: TextAlign.center,
                      style: TextStyle(
                        fontWeight: FontWeight.bold,
                        color: _tapFeedback!.contains('INSIDE')
                            ? Colors.green.shade800
                            : Colors.orange.shade800,
                      ),
                    ),
                  ),
                )
              : null,
        ),
        body: Column(
          spacing: 45,
          children: [
            /// Configuration toggles - wrapped in TapRegion to be considered
            /// "inside" the bubble (tapping here won't dismiss the bubble)
            TapRegion(
              groupId: BubbleLabel.tapRegionGroupId,
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: SizedBox(
                  height: 140,
                  child: Column(
                    spacing: 8,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      /// Toggle to allow/disallow bubble pointer events
                      Flexible(
                        child: Row(
                          spacing: 8,
                          children: [
                            const Text('Allow bubble pointer events'),
                            SToggle(
                              size: 40,
                              onColor: Colors.green,
                              offColor: Colors.red,
                              value: shouldIgnorePointer == false,
                              onChange: (val) {
                                setState(() => shouldIgnorePointer = !val);
                                // Update the active bubble if one is showing
                                BubbleLabel.updateContent(
                                  shouldIgnorePointer: shouldIgnorePointer,
                                );
                              },
                            ),
                          ],
                        ),
                      ),

                      /// Toggle to enable/disable animation
                      Flexible(
                        child: Row(
                          spacing: 8,
                          children: [
                            const Text('Animate'),
                            SToggle(
                              size: 40,
                              onColor: Colors.green,
                              offColor: Colors.red,
                              value: animate,
                              onChange: (val) => setState(() => animate = val),
                            ),
                          ],
                        ),
                      ),

                      /// Toggle to enable/disable overlay
                      Flexible(
                        child: Row(
                          spacing: 8,
                          children: [
                            const Text('Use overlay'),
                            SToggle(
                              size: 40,
                              onColor: Colors.green,
                              offColor: Colors.red,
                              value: useOverlay,
                              onChange: (val) =>
                                  setState(() => useOverlay = val),
                            ),
                          ],
                        ),
                      ),

                      /// Toggle to enable/disable transform wrapper
                      Flexible(
                        child: Row(
                          spacing: 8,
                          children: [
                            const Text('Wrap with Transform.scale'),
                            SToggle(
                              size: 40,
                              onColor: Colors.green,
                              offColor: Colors.red,
                              value: useTransform,
                              onChange: (val) {
                                setState(() {
                                  useTransform = val;
                                  if (val) useForcePhoneSize = false;
                                });
                              },
                            ),
                            if (useTransform)
                              Text(
                                '(${transformScale.toStringAsFixed(2)}x)',
                                style: TextStyle(
                                  color: Colors.grey.shade600,
                                  fontSize: 12,
                                ),
                              ),
                          ],
                        ),
                      ),

                      /// Toggle to enable/disable ForcePhoneSizeOnWeb wrapper
                      Flexible(
                        child: Row(
                          spacing: 8,
                          children: [
                            const Text('Wrap with ForcePhoneSizeOnWeb'),
                            SToggle(
                              size: 40,
                              onColor: Colors.green,
                              offColor: Colors.red,
                              value: useForcePhoneSize,
                              onChange: (val) {
                                setState(() {
                                  useForcePhoneSize = val;
                                  if (val) useTransform = false;
                                });
                              },
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),

            /// The main example page with buttons to show bubbles.
            Flexible(
              child: _buildExamplePageWithOptionalTransform(),
            ),

            /// Dismiss buttons
            Padding(
              padding: const EdgeInsets.only(bottom: 8.0),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                spacing: 15,
                children: [
                  ElevatedButton(
                    key: const Key('dismiss-button'),
                    onPressed: () => BubbleLabel.dismiss(animate: false),
                    child: const Text('Dismiss'),
                  ),
                  const SizedBox(width: 12),
                  ElevatedButton(
                    key: const Key('dismiss-button-animate'),
                    onPressed: () => BubbleLabel.dismiss(animate: true),
                    child: const Text('Dismiss (animated)'),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// A simple page with buttons that call `BubbleLabel.show` to display
/// sample bubbles so users can try out the package behavior.
class ExamplePage extends StatefulWidget {
  /// Whether to animate show/dismiss operations in this example page.
  final bool animate;

  /// Whether the example shows a background overlay while the bubble is active.
  final bool useOverlay;

  /// Whether the background overlay should ignore pointer events.
  final bool shouldIgnorePointer;

  /// Callback to show visual feedback for tap inside/outside.
  final void Function(String message, Color color)? onTapFeedback;

  /// Creates an `ExamplePage` used in the example app. It exposes two
  /// configurable options: [animate] and [useOverlay].
  const ExamplePage({
    super.key,
    this.animate = true,
    this.useOverlay = true,
    this.shouldIgnorePointer = true,
    this.onTapFeedback,
  });

  @override
  State<ExamplePage> createState() => _ExamplePageState();
}

class _ExamplePageState extends State<ExamplePage> {
  // GlobalKeys are only needed when you want to anchor to a different widget
  // than the one triggering the bubble, or for dynamic position tracking.
  final longPressKey = GlobalKey();
  final bubbleButtonKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      physics: const AlwaysScrollableScrollPhysics(),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.start,
        spacing: 12,
        children: [
          /// A simple button that shows a bubble when tapped.
          /// This demonstrates the simplified API where context is the anchor.
          Builder(
            builder: (buttonContext) {
              return ElevatedButton(
                onPressed: () {
                  BubbleLabel.show(
                    context: buttonContext, // Uses this button as anchor
                    bubbleContent: BubbleLabelContent(
                      child: const Padding(
                        padding: EdgeInsets.symmetric(horizontal: 8.0),
                        child: Text('Hello bubble!',
                            style: TextStyle(color: Colors.white)),
                      ),
                      bubbleColor: Colors.deepPurpleAccent,
                      backgroundOverlayLayerOpacity:
                          widget.useOverlay ? 0.3 : 0.0,
                    ),
                    animate: widget.animate,
                    // No anchorKey needed! Context is the anchor.
                  );
                },
                child: const Text('show bubble'),
              );
            },
          ),

          /// A simple button that shows a bubble without overlay when tapped.
          /// Also demonstrates simplified context-only API.
          Builder(
            builder: (buttonContext) {
              return ElevatedButton(
                onPressed: () {
                  final bubbleContent = BubbleLabelContent(
                    child: const Padding(
                      padding: EdgeInsets.symmetric(horizontal: 8.0),
                      child: Text(
                        'bubble 25px above Anchor widget',
                        style: TextStyle(color: Colors.white),
                      ),
                    ),
                    bubbleColor: Colors.green,
                    backgroundOverlayLayerOpacity: 0.0,
                    verticalPadding: 25,
                  );

                  BubbleLabel.show(
                    context: buttonContext,
                    bubbleContent: bubbleContent,
                    animate: widget.animate,
                  );
                },
                child: const Text('Bubble 25px above'),
              );
            },
          ),

          /// A long-press area that shows a bubble when long-pressed.
          GestureDetector(
            key: longPressKey,
            onLongPress: () {
              final bubbleContent = BubbleLabelContent(
                child: const Padding(
                  padding: EdgeInsets.symmetric(horizontal: 8.0),
                  child: Text('Long press bubble - tap inside/outside!'),
                ),
                bubbleColor: const Color.fromARGB(255, 239, 246, 35),
                backgroundOverlayLayerOpacity: widget.useOverlay ? 0.25 : 0.0,
                shouldActivateOnLongPressOnAllPlatforms: true,
                dismissOnBackgroundTap: true,
                // Visual feedback callbacks for tap detection
                onTapInside: (details) {
                  widget.onTapFeedback?.call(
                    'Tap INSIDE bubble detected!',
                    Colors.green,
                  );
                },
                onTapOutside: (details) {
                  widget.onTapFeedback?.call(
                    'Tap OUTSIDE bubble detected!',
                    Colors.orange,
                  );
                },
              );

              BubbleLabel.show(
                context: context,
                bubbleContent: bubbleContent,
                animate: widget.animate,
                anchorKey: longPressKey,
              );
            },
            child: Container(
              key: const Key('longpress-container'),
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
              decoration: BoxDecoration(
                color: Colors.blueGrey.shade50,
                borderRadius: BorderRadius.circular(8),
                border: Border.all(color: Colors.blueGrey.shade200),
              ),
              height: 90,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: const [
                  Text(
                    'Long-press to show bubble',
                    textAlign: TextAlign.center,
                    style: TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.w800,
                    ),
                  ),
                  SizedBox(height: 4),
                  Text(
                    'Tap on background to dismiss',
                    textAlign: TextAlign.center,
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w400,
                    ),
                  ),
                ],
              ),
            ),
          ),

          /// A long-press area that shows a bubble with a button inside when long-pressed.
          GestureDetector(
            key: bubbleButtonKey,
            onTap: () {
              final bubbleContent = BubbleLabelContent(
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 8.0),
                  child: Material(
                    color: Colors.transparent,
                    child: InkWell(
                      onTap: () {
                        debugPrint('Button inside bubble tapped');
                      },
                      splashColor: Colors.blue,
                      highlightColor: Colors.blue,
                      borderRadius: BorderRadius.circular(4),
                      child: Container(
                        decoration: BoxDecoration(
                          color: Colors.white.withValues(alpha: 0.2),
                          borderRadius: BorderRadius.circular(4),
                          border: Border.all(color: Colors.black12),
                        ),
                        padding: const EdgeInsets.all(6.0),
                        child: const Text('button'),
                      ),
                    ),
                  ),
                ),
                bubbleColor: Colors.greenAccent,
                backgroundOverlayLayerOpacity: widget.useOverlay ? 0.25 : 0.0,
                shouldActivateOnLongPressOnAllPlatforms: true,
                dismissOnBackgroundTap: true,
                shouldIgnorePointer: widget.shouldIgnorePointer,
              );

              BubbleLabel.show(
                context: context,
                bubbleContent: bubbleContent,
                animate: widget.animate,
                anchorKey: bubbleButtonKey,
              );
            },
            child: Container(
              key: const Key('tap-container-button'),
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
              decoration: BoxDecoration(
                color: Colors.blueGrey.shade100,
                borderRadius: BorderRadius.circular(8),
                border: Border.all(color: Colors.blueGrey.shade300),
              ),
              height: 90,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: const [
                  Text(
                    'Tap widget',
                    textAlign: TextAlign.center,
                    style: TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.w800,
                    ),
                  ),
                  SizedBox(height: 4),
                  Text(
                    'A bubble with a button inside',
                    textAlign: TextAlign.center,
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w400,
                    ),
                  ),
                ],
              ),
            ),
          ),

          /// A simple button that shows a bubble at a custom position when tapped.
          ElevatedButton(
            onPressed: () {
              BubbleLabel.show(
                bubbleContent: BubbleLabelContent(
                  child: const Padding(
                    padding: EdgeInsets.symmetric(horizontal: 8.0),
                    child: Text('Position override bubble at (400, 150)'),
                  ),
                  bubbleColor: Colors.tealAccent,
                  positionOverride: const Offset(400, 150),
                  backgroundOverlayLayerOpacity: widget.useOverlay ? 0.35 : 0.0,
                  dismissOnBackgroundTap: true,
                ),
                animate: widget.animate,
                context: context,
              );
            },
            child: const Text(
              'Bubble\nOffset(400, 150)',
              textAlign: TextAlign.center,
            ),
          ),
        ],
      ),
    );
  }
}
0
likes
160
points
316
downloads
screenshot

Publisher

unverified uploader

Weekly Downloads

A small Flutter package that shows a floating bubble label anchored to a child widget, and optional background overlay + simple show/dismiss animations.

Repository (GitHub)
View/report issues

Topics

#ui #overlay #tooltip #bubble #widget

Documentation

API reference

License

MIT (license)

Dependencies

assorted_layout_widgets, flutter, flutter_animate, sizer, soundsliced_dart_extensions, states_rebuilder_extended, xid

More

Packages that depend on bubble_label