mirror of
https://github.com/flutter/samples.git
synced 2025-11-11 15:28:44 +00:00
385 lines
11 KiB
Dart
385 lines
11 KiB
Dart
// 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;
|
|
|
|
FeatureDiscoveryController(this.child);
|
|
|
|
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
|
|
_FeatureDiscoveryControllerState 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 final overlayKey = Key('overlay key');
|
|
|
|
@visibleForTesting
|
|
static final gestureDetectorKey = Key('gesture detector key');
|
|
|
|
FeatureDiscovery({
|
|
@required this.title,
|
|
@required this.description,
|
|
@required this.child,
|
|
@required this.showOverlay,
|
|
this.onDismiss,
|
|
this.onTap,
|
|
this.color,
|
|
}) {
|
|
assert(title != null);
|
|
assert(description != null);
|
|
assert(child != null);
|
|
assert(showOverlay != null);
|
|
}
|
|
|
|
@override
|
|
_FeatureDiscoveryState createState() => _FeatureDiscoveryState();
|
|
}
|
|
|
|
class _FeatureDiscoveryState extends State<FeatureDiscovery>
|
|
with TickerProviderStateMixin {
|
|
bool showOverlay = false;
|
|
FeatureDiscoveryStatus status = FeatureDiscoveryStatus.closed;
|
|
|
|
AnimationController openController;
|
|
AnimationController rippleController;
|
|
AnimationController tapController;
|
|
AnimationController dismissController;
|
|
|
|
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).primaryColor;
|
|
|
|
// Wrap in transparent [Material] to enable widgets that require one.
|
|
return Material(
|
|
key: FeatureDiscovery.overlayKey,
|
|
type: MaterialType.transparency,
|
|
child: Stack(
|
|
children: [
|
|
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,
|
|
child: widget.child,
|
|
onTap: tap,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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() {
|
|
assert(openController != null);
|
|
assert(rippleController != null);
|
|
assert(tapController != null);
|
|
assert(dismissController != null);
|
|
|
|
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();
|
|
}
|
|
}
|