blob: 353d758f4b4a61d2e0f585082aabc83f211b3303 [file] [log] [blame]
// Copyright 2019 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:gallery/feature_discovery/animation.dart';
import 'package:gallery/feature_discovery/overlay.dart';
/// [Widget] to enforce a global lock system for [FeatureDiscovery] widgets.
///
/// This widget enforces that at most one [FeatureDiscovery] widget in its
/// widget tree is shown at a time.
///
/// Users wanting to use [FeatureDiscovery] need to put this controller
/// above [FeatureDiscovery] widgets in the widget tree.
class FeatureDiscoveryController extends StatefulWidget {
final Widget child;
const FeatureDiscoveryController(this.child, {super.key});
static _FeatureDiscoveryControllerState _of(BuildContext context) {
final matchResult =
context.findAncestorStateOfType<_FeatureDiscoveryControllerState>();
if (matchResult != null) {
return matchResult;
}
throw FlutterError(
'FeatureDiscoveryController.of() called with a context that does not '
'contain a FeatureDiscoveryController.\n The context used was:\n '
'$context');
}
@override
State<FeatureDiscoveryController> createState() =>
_FeatureDiscoveryControllerState();
}
class _FeatureDiscoveryControllerState
extends State<FeatureDiscoveryController> {
bool _isLocked = false;
/// Flag to indicate whether a [FeatureDiscovery] widget descendant is
/// currently showing its overlay or not.
///
/// If true, then no other [FeatureDiscovery] widget should display its
/// overlay.
bool get isLocked => _isLocked;
/// Lock the controller.
///
/// Note we do not [setState] here because this function will be called
/// by the first [FeatureDiscovery] ready to show its overlay, and any
/// additional [FeatureDiscovery] widgets wanting to show their overlays
/// will already be scheduled to be built, so the lock change will be caught
/// in their builds.
void lock() => _isLocked = true;
/// Unlock the controller.
void unlock() => setState(() => _isLocked = false);
@override
void didChangeDependencies() {
super.didChangeDependencies();
assert(
context.findAncestorStateOfType<_FeatureDiscoveryControllerState>() ==
null,
'There should not be another ancestor of type '
'FeatureDiscoveryController in the widget tree.',
);
}
@override
Widget build(BuildContext context) => widget.child;
}
/// Widget that highlights the [child] with an overlay.
///
/// This widget loosely follows the guidelines set forth in the Material Specs:
/// https://material.io/archive/guidelines/growth-communications/feature-discovery.html.
class FeatureDiscovery extends StatefulWidget {
/// Title to be displayed in the overlay.
final String title;
/// Description to be displayed in the overlay.
final String description;
/// Icon to be promoted.
final Icon child;
/// Flag to indicate whether to show the overlay or not anchored to the
/// [child].
final bool showOverlay;
/// Callback invoked when the user dismisses an overlay.
final void Function()? onDismiss;
/// Callback invoked when the user taps on the tap target of an overlay.
final void Function()? onTap;
/// Color with which to fill the outer circle.
final Color? color;
@visibleForTesting
static const overlayKey = Key('overlay key');
@visibleForTesting
static const gestureDetectorKey = Key('gesture detector key');
const FeatureDiscovery({
super.key,
required this.title,
required this.description,
required this.child,
required this.showOverlay,
this.onDismiss,
this.onTap,
this.color,
});
@override
State<FeatureDiscovery> createState() => _FeatureDiscoveryState();
}
class _FeatureDiscoveryState extends State<FeatureDiscovery>
with TickerProviderStateMixin {
bool showOverlay = false;
FeatureDiscoveryStatus status = FeatureDiscoveryStatus.closed;
late AnimationController openController;
late AnimationController rippleController;
late AnimationController tapController;
late AnimationController dismissController;
late Animations animations;
OverlayEntry? overlay;
Widget buildOverlay(BuildContext ctx, Offset center) {
debugCheckHasMediaQuery(ctx);
debugCheckHasDirectionality(ctx);
final deviceSize = MediaQuery.of(ctx).size;
final color = widget.color ?? Theme.of(ctx).colorScheme.primary;
// Wrap in transparent [Material] to enable widgets that require one.
return Material(
key: FeatureDiscovery.overlayKey,
type: MaterialType.transparency,
child: Stack(
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
key: FeatureDiscovery.gestureDetectorKey,
onTap: dismiss,
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.transparent,
),
),
),
Background(
animations: animations,
status: status,
color: color,
center: center,
deviceSize: deviceSize,
textDirection: Directionality.of(ctx),
),
Content(
animations: animations,
status: status,
center: center,
deviceSize: deviceSize,
title: widget.title,
description: widget.description,
textTheme: Theme.of(ctx).textTheme,
),
Ripple(
animations: animations,
status: status,
center: center,
),
TapTarget(
animations: animations,
status: status,
center: center,
onTap: tap,
child: widget.child,
),
],
),
);
}
/// Method to handle user tap on [TapTarget].
///
/// Tapping will stop any active controller and start the [tapController].
void tap() {
openController.stop();
rippleController.stop();
dismissController.stop();
tapController.forward(from: 0.0);
}
/// Method to handle user dismissal.
///
/// Dismissal will stop any active controller and start the
/// [dismissController].
void dismiss() {
openController.stop();
rippleController.stop();
tapController.stop();
dismissController.forward(from: 0.0);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (ctx, _) {
if (overlay != null) {
SchedulerBinding.instance.addPostFrameCallback((_) {
// [OverlayEntry] needs to be explicitly rebuilt when necessary.
overlay!.markNeedsBuild();
});
} else {
if (showOverlay && !FeatureDiscoveryController._of(ctx).isLocked) {
final entry = OverlayEntry(
builder: (_) => buildOverlay(ctx, getOverlayCenter(ctx)),
);
// Lock [FeatureDiscoveryController] early in order to prevent
// another [FeatureDiscovery] widget from trying to show its
// overlay while the post frame callback and set state are not
// complete.
FeatureDiscoveryController._of(ctx).lock();
SchedulerBinding.instance.addPostFrameCallback((_) {
setState(() {
overlay = entry;
status = FeatureDiscoveryStatus.closed;
openController.forward(from: 0.0);
});
Overlay.of(context).insert(entry);
});
}
}
return widget.child;
});
}
/// Compute the center position of the overlay.
Offset getOverlayCenter(BuildContext parentCtx) {
final box = parentCtx.findRenderObject() as RenderBox;
final size = box.size;
final topLeftPosition = box.localToGlobal(Offset.zero);
final centerPosition = Offset(
topLeftPosition.dx + size.width / 2,
topLeftPosition.dy + size.height / 2,
);
return centerPosition;
}
@override
void initState() {
super.initState();
initAnimationControllers();
initAnimations();
showOverlay = widget.showOverlay;
}
void initAnimationControllers() {
openController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
)
..addListener(() {
setState(() {});
})
..addStatusListener((animationStatus) {
if (animationStatus == AnimationStatus.forward) {
setState(() => status = FeatureDiscoveryStatus.open);
} else if (animationStatus == AnimationStatus.completed) {
rippleController.forward(from: 0.0);
}
});
rippleController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
)
..addListener(() {
setState(() {});
})
..addStatusListener((animationStatus) {
if (animationStatus == AnimationStatus.forward) {
setState(() => status = FeatureDiscoveryStatus.ripple);
} else if (animationStatus == AnimationStatus.completed) {
rippleController.forward(from: 0.0);
}
});
tapController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
)
..addListener(() {
setState(() {});
})
..addStatusListener((animationStatus) {
if (animationStatus == AnimationStatus.forward) {
setState(() => status = FeatureDiscoveryStatus.tap);
} else if (animationStatus == AnimationStatus.completed) {
widget.onTap?.call();
cleanUponOverlayClose();
}
});
dismissController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
)
..addListener(() {
setState(() {});
})
..addStatusListener((animationStatus) {
if (animationStatus == AnimationStatus.forward) {
setState(() => status = FeatureDiscoveryStatus.dismiss);
} else if (animationStatus == AnimationStatus.completed) {
widget.onDismiss?.call();
cleanUponOverlayClose();
}
});
}
void initAnimations() {
animations = Animations(
openController,
tapController,
rippleController,
dismissController,
);
}
/// Clean up once overlay has been dismissed or tap target has been tapped.
///
/// This is called upon [tapController] and [dismissController] end.
void cleanUponOverlayClose() {
FeatureDiscoveryController._of(context).unlock();
setState(() {
status = FeatureDiscoveryStatus.closed;
showOverlay = false;
overlay?.remove();
overlay = null;
});
}
@override
void didUpdateWidget(FeatureDiscovery oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.showOverlay != oldWidget.showOverlay) {
showOverlay = widget.showOverlay;
}
}
@override
void dispose() {
overlay?.remove();
openController.dispose();
rippleController.dispose();
tapController.dispose();
dismissController.dispose();
super.dispose();
}
}