In many apps, when you open a page for the first time, you mask the entire page and only highlight what’s important. In the Android native implementation basically is to add a layer of boot layout on the root view, add and delete the boot view on this layout, until the completion of the removal of the layout, so that the original page has no impact. This approach is not appropriate given the particularity of Flutter. Here we take a different approach and push a new widget on the page that needs to be bootstrapped. This widget is responsible for bootstrapped similar to the layout we introduced above. Let’s start by looking at what this article is trying to achieve.
The original page
Guide on page 1
Guide on page 2
Guide page 3
Guide page 4
Front knowledge
We have a few minor issues to resolve before we can get started: Since we are using the new push widget, we have to make it transparent; Push timing of new widgets.
– Transparent: in fact, the system has provided us well, we build the mask layout (GuideLayout), in his build method only need to return a Material component, of course, need to configure a property type; For example, here,
@override Widget build(BuildContext context) { Size screenSize = MediaQuery.of(context).size; return Material( color: Color(0x00ffffff), type: MaterialType.transparency, child: ... }}Copy the code
This property is configured because it was commented in the introduction to Material. To create a transparent piece of material, use [MaterialType.transparency].
– Timing: If the network request is required after the display, it must be the network request back directly push. But for static pages, we would normally register the listener implementation in initState. Flutter provides us with a listener. These listeners are in the WidgetsBinding class. They can listen for whether the current page is in the foreground or background, and when the drawing is complete. Here we use the drawn listener.
WidgetsBinding.instance.addPostFrameCallback();
Copy the code
This method will call back after the current frame ends, and only once. If familiar with Android, it is similar to View.post. So far the front work is finished, the formal start of the pop-up page logic.
Effect analysis and preparation
Before we start, let’s look at what we need to make this work. First of all, the whole popup page has a mask layer, only the part that needs to be guided has no mask layer, and the shape of the part that needs to be guided without mask layer is rectangle, rounded rectangle, ellipse and circle. The second widget that needs to be booted has a widget for instructions nearby. Finally, only one widget that needs to be booted is displayed at a time, and when the last display is complete, pop the widget. For these three parts, the most critical thing is to “dig holes” on the mask, that is, to deduct specific areas on the mask, which needs to use curve fitting. This implementation was introduced in the previous implementation of the Flutter custom View, but not in depth. Here are the highlights.
Curve fitting of preparation
Curve fitting we adopt Path result = Path.com bine (PathOperation difference, path1, path2); The most important parameter here is the first parameter, which determines the area where the final curve retains two curves. There are five values in total. The following is a schematic diagram to show the fitting effect of each value
Here path1 is a circle, path2 is a rectangle. In order to achieve the mask “hole digging” effect, actually Path1 can be a full-screen rectangle, the brush color is the mask color, and the area to be dug is Path2. Comparing several fitting effects, the first parameter of curve fitting should be pathOperation. difference. So far mask dig hole also realized.
Prepare the bootstrap data class
Comparing the effect of the bootstrap, we can see that the main things needed to add the bootstrap are,
- “Hole” area and hole position: childSize indicates the size, Offset indicates the Offset from the left side of the screen and the previous book.
- “Caption text” and location: Quoted caption text is defined as a Widget because the caption is not just a piece of text but may also be accompanied by an image.
- Click callback: click on a boot often needs to store the relevant data, so the click event needs to be passed, because it is bound to a single boot we only need to pass a function, so use the system defined
typedef GestureTapCallback = void Function();
- Click on components to close: Bootstrap pages are usually closed by clicking anywhere, but sometimes we want the user to only click on “bootstrap area” to close, so we need to define a bool value to indicate that the default is to close the whole area.
- Shape of guide areas: As you can see from the four guide diagrams at the beginning, there are four types of guide areas, so we can define an enumeration to represent them
RECTANGLE {ROUND_RECTANGLE, ROUND_RECTANGLE, ROUND_RECTANGLE, ROUND_RECTANGLE, ROUND_RECTANGLE, ROUND_RECTANGLE}Copy the code
The data class is fully defined as follows
Class GuideChild {// Highlight widget Size childSize; // Highlight the widget's position (Offset) Offset; RECTANGLE = RECTANGLE; RECTANGLE = RECTANGLE; RECTANGLE = RECTANGLE; // Widget descWidget; // Used to explain the position of the component highlighting the widget Offset descOffset; // Click the component's callback GestureTapCallback callback; Bool closeByClickChild = false; double padding = 5; }Copy the code
This concludes the preparations.
implementation
Page background, since we use the curve fitting method to draw, so we use CustomPainter to draw the background. Since it is completely covered, the Material child should be CustomPaint, and since the entire page can be clicked, the Material child should be GestureDetector. CustomPaint acts as the Child of the GestureDetector. This CustomPaint should be the size of the entire screen, so the “description text” that needs to be displayed can be its child. Because we have been given the position of the Child, the Child of CustomPaint should be a Stack+Positioned component, and thus can be displayed in a given position. So build code is
@override
Widget build(BuildContext context) {
Size screenSize = MediaQuery.of(context).size;
return Material(
color: Color(0x00ffffff),
type: MaterialType.transparency,
child: GestureDetector(
onTapUp: tapUp,
child: CustomPaint(
size: screenSize,
painter: BgPainter(
offset: widget.children.first.offset,
childSize: widget.children.first.childSize,
shape: widget.children.first.childShape,
padding: widget.children.first.padding),
child: Stack(
children: [
Positioned(
child: widget.children.first.descWidget,
left: widget.children.first.descOffset.dx,
top: widget.children.first.descOffset.dy,
)
],
),
),
),
);
}
Copy the code
Let’s analyze the implementation of the big box. In the GestureDetector we listen for the onTapUp event, mainly because in this method we can get the position of the finger when it is lifted so that we can determine the position of the click when the request is closed only by clicking on the “boot area”. The logic of clicking is as follows
void tapUp(TapUpDetails details) { print("tapUp==>>${details.globalPosition}"); if (widget.children.first.closeByClickChild) { Path path = new Path(); path.addRect(Rect.fromLTWH( widget.children.first.offset.dx, widget.children.first.offset.dy, widget.children.first.childSize.width, widget.children.first.childSize.height)); if (! path.contains(details.globalPosition)) return; } widget.children.first.callback? .call(); widget.children.removeAt(0); if (widget.children.length == 0) { widget.onCompete? .call(); Navigator.of(context).pop(); } else { setState(() {}); } print("length==>>${widget.children.length}"); }}Copy the code
When set to “boot zone” only, we will determine if the page is in the zone. In other cases we call back the click-through callback and make the current page display the next boot that needs to be displayed. Close the GuideLayout if there is no boot to display. (Only the rectangle area is judged here, if you are interested, you can add some other types of areas.) What is left is the background drawing
class BgPainter extends CustomPainter { Offset offset; Size childSize; Path path1; Path path2; Paint _paint; ChildShape shape; double padding; BgPainter({this.offset, this.childSize, this.shape, this.padding}) { path1 = Path(); path2 = Path(); _paint = Paint() .. color = Color(0x90000000) .. style = PaintingStyle.fill .. isAntiAlias = true; } @override void paint(Canvas canvas, Size size) { path1.reset(); path2.reset(); path1.addRect(Rect.fromLTWH(0, 0, size.width, size.height)); switch (shape) { case ChildShape.RECTANGLE: path2.addRect(Rect.fromLTWH(offset.dx - padding, offset.dy - padding, childSize.width + padding * 2, childSize.height + padding * 2)); break; case ChildShape.CIRCLE: double length; double left; double top; double radius = sqrt(childSize.width * childSize.width + childSize.height * childSize.height); length = radius + padding * 2; left = offset.dx - (radius - childSize.width) / 2 - padding; top = offset.dy - (radius - childSize.height) / 2 - padding; path2.addOval(Rect.fromLTWH(left, top, length, length)); break; case ChildShape.OVAL: double length; double left; double top; double radius = sqrt(childSize.width * childSize.width + childSize.height * childSize.height); length = radius + padding * 2; left = offset.dx - (radius + padding * 4 - childSize.width) / 2 - padding; top = offset.dy - (radius - childSize.height) / 2 - padding; path2.addOval(Rect.fromLTWH( left, top, length + padding * 6, length + padding * 2)); break; case ChildShape.ROUND_RECTANGLE: path2.addRRect(RRect.fromRectXY( Rect.fromLTWH(offset.dx - padding, offset.dy - padding, childSize.width + padding * 2, childSize.height + padding * 2), padding * 2, padding * 2)); break; } Path result = Path.combine(PathOperation.difference, path1, path2); canvas.drawPath(result, _paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return false; }}Copy the code
The key is to draw circles, ellipses and rounded rectangles with path. The drawing is almost done. This layout also provides calls to the outside world, using static methods
static void showGuide(BuildContext context, List<GuideChild> children, GestureTapCallback onComplete) { Navigator.of(context).push(PageRouteBuilder( pageBuilder: (context, animation, secAnim) {return FadeTransition(/// / opacity 1.0-1.0: Tween(begin: 0.0, end: Parent: animation (/// parent: animation, /// / Curve: Curves. FastOutSlowIn,), child: GuideLayout( children, onCompete: onComplete, ), ); }, opaque: false)); }}Copy the code
Context is needed because it needs to be pushed, children is all that needs to be booted, and onComplete is the callback that is provided to the caller when all the booting is done. So far the whole definition is complete, complete code
import 'dart:math'; import 'package:flutter/material.dart'; RECTANGLE size {RECTANGLE size, RECTANGLE size, RECTANGLE size, RECTANGLE size; // ROUND_RECTANGLE // ROUND_RECTANGLE // rectangle} class GuideChild {// highlight the widget Size childSize; // Highlight the widget's position (Offset) Offset; RECTANGLE = RECTANGLE; RECTANGLE = RECTANGLE; RECTANGLE = RECTANGLE; // Widget descWidget; // Used to explain the position of the component highlighting the widget Offset descOffset; // Click the component's callback GestureTapCallback callback; Bool closeByClickChild = false; double padding = 5; } class GuideLayout extends StatefulWidget { final List<GuideChild> children; final GestureTapCallback onCompete; GuideLayout(this.children, {this.onCompete}); @override State<StatefulWidget> createState() { return GuideLayoutState(); } static void showGuide(BuildContext context, List<GuideChild> children, GestureTapCallback onComplete) { Navigator.of(context).push(PageRouteBuilder( pageBuilder: (context, animation, secAnim) {return FadeTransition(/// / opacity 1.0-1.0: Tween(begin: 0.0, end: Parent: animation (/// parent: animation, /// / Curve: Curves. FastOutSlowIn,), child: GuideLayout( children, onCompete: onComplete, ), ); }, opaque: false)); } } class GuideLayoutState extends State<GuideLayout> { @override Widget build(BuildContext context) { Size screenSize = MediaQuery.of(context).size; return Material( color: Color(0x00ffffff), type: MaterialType.transparency, child: GestureDetector( onTapUp: tapUp, child: CustomPaint( size: screenSize, painter: BgPainter( offset: widget.children.first.offset, childSize: widget.children.first.childSize, shape: widget.children.first.childShape, padding: widget.children.first.padding), child: Stack( children: [ Positioned( child: widget.children.first.descWidget, left: widget.children.first.descOffset.dx, top: widget.children.first.descOffset.dy, ) ], ), ), ), ); } void tapChild() { widget.children.first.callback? .call(); setState(() { if (widget.children.length == 1) { widget.onCompete? .call(); Navigator.of(context).pop(); } else if (widget.children.length > 1) { widget.children.removeAt(0); }}); } void tapUp(TapUpDetails details) { print("tapUp==>>${details.globalPosition}"); if (widget.children.first.closeByClickChild) { Path path = new Path(); path.addRect(Rect.fromLTWH( widget.children.first.offset.dx, widget.children.first.offset.dy, widget.children.first.childSize.width, widget.children.first.childSize.height)); if (! path.contains(details.globalPosition)) return; } widget.children.first.callback? .call(); widget.children.removeAt(0); if (widget.children.length == 0) { widget.onCompete? .call(); Navigator.of(context).pop(); } else { setState(() {}); } print("length==>>${widget.children.length}"); } } class BgPainter extends CustomPainter { Offset offset; Size childSize; Path path1; Path path2; Paint _paint; ChildShape shape; double padding; BgPainter({this.offset, this.childSize, this.shape, this.padding}) { path1 = Path(); path2 = Path(); _paint = Paint() .. color = Color(0x90000000) .. style = PaintingStyle.fill .. isAntiAlias = true; } @override void paint(Canvas canvas, Size size) { path1.reset(); path2.reset(); path1.addRect(Rect.fromLTWH(0, 0, size.width, size.height)); switch (shape) { case ChildShape.RECTANGLE: path2.addRect(Rect.fromLTWH(offset.dx - padding, offset.dy - padding, childSize.width + padding * 2, childSize.height + padding * 2)); break; case ChildShape.CIRCLE: double length; double left; double top; double radius = sqrt(childSize.width * childSize.width + childSize.height * childSize.height); length = radius + padding * 2; left = offset.dx - (radius - childSize.width) / 2 - padding; top = offset.dy - (radius - childSize.height) / 2 - padding; path2.addOval(Rect.fromLTWH(left, top, length, length)); break; case ChildShape.OVAL: double length; double left; double top; double radius = sqrt(childSize.width * childSize.width + childSize.height * childSize.height); length = radius + padding * 2; left = offset.dx - (radius + padding * 4 - childSize.width) / 2 - padding; top = offset.dy - (radius - childSize.height) / 2 - padding; path2.addOval(Rect.fromLTWH( left, top, length + padding * 6, length + padding * 2)); break; case ChildShape.ROUND_RECTANGLE: path2.addRRect(RRect.fromRectXY( Rect.fromLTWH(offset.dx - padding, offset.dy - padding, childSize.width + padding * 2, childSize.height + padding * 2), padding * 2, padding * 2)); break; } Path result = Path.combine(PathOperation.difference, path1, path2); canvas.drawPath(result, _paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return false; }}Copy the code
use
Using the defined GuideLayout mode is
GuideLayout.showGuide(context, children, onComplete);
Copy the code
Here children is a list, which is the collection of GuideChild, and onComplete is the callback that guides the presentation to completion. The special point to note here is how the GuideChild is constructed. Because we need information such as the child’s size and offset, we assign a value to the key of the widget we want to bootstrap, in this case globalKey. The information available for this globalKey is as follows
RenderBox renderBox = _globalKey.currentContext.findRenderObject(); if (! renderBox.size.isEmpty) { Offset childOffset = renderBox.localToGlobal(Offset.zero); Size childSize = renderBox.size; }Copy the code
RenderBox. Size is the size of the widget that bound the key, RenderBox. LocalToGlobal (Offset. You can get the offset of the widget to which the key is bound. All that remains is the build specification widget, which depends on the specific requirements, and its display location depending on the needs, and the required build Child is complete. Implementation of the opening effect of the call code as follows, interested in direct paste play.
import 'package:flutter/material.dart'; import 'package:myapp/guide_layout.dart'; class GuideTest extends StatefulWidget { @override State<StatefulWidget> createState() { return _GuideTestState(); } } class _GuideTestState extends State<GuideTest> { GlobalKey<_GuideTestState> _globalKey = new GlobalKey(); GlobalKey<_GuideTestState> _globalKey2 = new GlobalKey(); GlobalKey<_GuideTestState> _globalKey3 = new GlobalKey(); GlobalKey<_GuideTestState> _globalKey4 = new GlobalKey(); List<GuideChild> children = []; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { print("timeStamp==>>$timeStamp"); RenderBox renderBox = _globalKey.currentContext.findRenderObject(); RenderBox renderBox2 = _globalKey2.currentContext.findRenderObject(); RenderBox renderBox3 = _globalKey3.currentContext.findRenderObject(); RenderBox renderBox4 = _globalKey4.currentContext.findRenderObject(); if (! renderBox.size.isEmpty) { Offset childOffset = renderBox.localToGlobal(Offset.zero); print(childOffset); Offset descOffset = Offset(10, childOffset.dy + renderBox.size.height + 10); children.add(new GuideChild() .. offset = childOffset .. childSize = renderBox.size .. descOffset = descOffset .. descWidget = getDescWidget() .. callback = callback1 .. closeByClickChild = true .. childShape = ChildShape.RECTANGLE); Offset childOffset2 = renderBox2.localToGlobal(Offset.zero); Offset descOffset2 = Offset(100, childOffset2.dy - 50); children.add(new GuideChild() .. offset = childOffset2 .. childSize = renderBox2.size .. descOffset = descOffset2 .. descWidget = getDescWidget() .. callback = callback2 .. childShape = ChildShape.ROUND_RECTANGLE); Offset childOffset3 = renderBox3.localToGlobal(Offset.zero); Offset descOffset3 = Offset(50, childOffset3.dy + renderBox3.size.height +50); children.add(new GuideChild() .. offset = childOffset3 .. childSize = renderBox3.size .. descOffset = descOffset3 .. descWidget = getDescWidget() .. callback = callback3 .. childShape = ChildShape.OVAL.. padding=3); Offset childOffset4 = renderBox4.localToGlobal(Offset.zero); Offset descOffset4 = Offset(180, childOffset4.dy + renderBox4.size.height + 30); children.add(new GuideChild() .. offset = childOffset4 .. childSize = renderBox4.size .. descOffset = descOffset4 .. descWidget = getDescWidget() .. callback = callback4 .. childShape = ChildShape.CIRCLE); GuideLayout.showGuide(context, children, onComplete); }}); } Widget getDescWidget() {return Container(child: DecoratedBox(child: Text(' this is a guide to the Text description ', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w800), ), decoration: BoxDecoration(color: Colors.greenAccent), ), ); } @override Widget build(BuildContext context) {return Scaffold(appBar: appBar (title: Text(" test boot "),), body: Center( child: Stack( children: [ Positioned( left: 30, top: 30, child: Container( height: 100, width: 100, key: _globalKey, color: color. blue, child: Center(child: Text(" test Text 1", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), ), ), )), Positioned( left: 100, top: 600, child: Container( key: _globalKey2, height: 100, width: 200, color: color. red, child: Center(child: Text(" test Text 2", style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), ), ), ), ), Positioned( left: 50, top: 400, child: Container( key: _globalKey3, height: 100, width: 150, color: Colors.purple, child: Center( child: TextStyle(fontSize: 20, fontWeight: fontWeight. Bold, color: Colors.white), ), ), ), ), Positioned( left: 230, top: 250, child: Container( key: _globalKey4, height: 120, width: 100, color: Colors. LightGreenAccent, child: Center(child: Text(" test Text 4", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), ), ), ), ) ], ), ), ); } void callback1() {print(" click on the first boot "); } void callback2() {print(" click on the second boot "); } void callback3() {print(" click on the third boot "); } void callback4() {print(" click on the fourth boot "); } void onComplete() {print(" all finished "); }}Copy the code
Keep clicking the boot log below
And you can see that basically meets our needs