From dc02d71d22298a4e8e992cc88f074963047bce1d Mon Sep 17 00:00:00 2001 From: tanay Date: Tue, 9 Feb 2021 23:27:05 -0500 Subject: [PATCH] Add support for anchor links within the same document --- example/lib/main.dart | 6 + lib/flutter_html.dart | 28 ++++ lib/html_parser.dart | 366 ++++++++++++++++++++++++++++-------------- lib/src/utils.dart | 122 ++++++++++++++ lib/style.dart | 20 +++ 5 files changed, 424 insertions(+), 118 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5d8d5b75c5..1966998e48 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -146,6 +146,12 @@ class _MyHomePageState extends State { title: Text('flutter_html Example'), centerTitle: true, ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Html.scrollToTop(); + }, + child: Icon(Icons.expand_less) + ), body: SingleChildScrollView( child: Html( data: htmlData, diff --git a/lib/flutter_html.dart b/lib/flutter_html.dart index faf8468d3a..e4d74205bd 100644 --- a/lib/flutter_html.dart +++ b/lib/flutter_html.dart @@ -1,6 +1,7 @@ library flutter_html; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_html/html_parser.dart'; import 'package:flutter_html/image_render.dart'; import 'package:flutter_html/style.dart'; @@ -69,6 +70,33 @@ class Html extends StatelessWidget { /// to use NavigationDelegate. final NavigationDelegate navigationDelegateForIframe; + /// Scrolls the HTML widget to the top. + /// You can set the duration and curve to use, by default the duration is 100ms and the curve is [Curves.easeIn] + /// This method should not be called until the widget is fully built. + static void scrollToTop({Duration duration, Curves curve}) { + final context = scrollContext; + final renderObject = context.findRenderObject(); + if (renderObject == null) return; + + final offsetToReveal = RenderAbstractViewport.of(renderObject) + ?.getOffsetToReveal(renderObject, 0.0) + ?.offset; + final position = Scrollable.of(scrollContext)?.position; + if (offsetToReveal == null || position == null) return; + + final alignment = (position.pixels > offsetToReveal) + ? 0.0 + : (position.pixels < offsetToReveal ? 1.0 : null); + if (alignment == null) return; + + position.ensureVisible( + renderObject, + alignment: alignment, + duration: duration ?? const Duration(milliseconds: 100), + curve: curve ?? Curves.easeIn, + ); + } + @override Widget build(BuildContext context) { final double width = shrinkWrap ? null : MediaQuery.of(context).size.width; diff --git a/lib/html_parser.dart b/lib/html_parser.dart index 91cec7a880..5b0c9ba912 100644 --- a/lib/html_parser.dart +++ b/lib/html_parser.dart @@ -5,6 +5,7 @@ import 'package:csslib/parser.dart' as cssparser; import 'package:csslib/visitor.dart' as css; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/image_render.dart'; import 'package:flutter_html/src/css_parser.dart'; @@ -24,18 +25,21 @@ typedef CustomRender = dynamic Function( dom.Element element, ); +BuildContext scrollContext; + class HtmlParser extends StatelessWidget { final String htmlData; final OnTap onLinkTap; final OnTap onImageTap; final ImageErrorListener onImageError; final bool shrinkWrap; - + final ScrollController scrollController = ScrollController(); final Map style; final Map customRender; final Map imageRenders; final List blacklistedElements; final NavigationDelegate navigationDelegateForIframe; + final Map buildContexts = {}; HtmlParser({ @required this.htmlData, @@ -76,15 +80,21 @@ class HtmlParser extends StatelessWidget { // using textScaleFactor = 1.0 (which is the default). This ensures the correct // scaling is used, but relies on https://github.com/flutter/flutter/pull/59711 // to wrap everything when larger accessibility fonts are used. - return StyledText( - textSpan: parsedTree, - style: cleanedTree.style, - textScaleFactor: MediaQuery.of(context).textScaleFactor, - renderContext: RenderContext( - buildContext: context, - parser: this, - style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2), - ), + return Scrollable( + controller: scrollController, + viewportBuilder: (BuildContext context, ViewportOffset position) { + scrollContext = context; + return StyledText( + textSpan: parsedTree, + style: cleanedTree.style, + textScaleFactor: MediaQuery.of(context).textScaleFactor, + renderContext: RenderContext( + buildContext: context, + parser: this, + style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2), + ), + ); + }, ); } @@ -247,7 +257,7 @@ class HtmlParser extends StatelessWidget { RenderContext newContext = RenderContext( buildContext: context.buildContext, parser: this, - style: context.style.copyOnlyInherited(tree.style), + style: context.style?.copyOnlyInherited(tree.style) ?? Style.fromTextStyle(Theme.of(context.buildContext).textTheme.bodyText2), ); if (customRender?.containsKey(tree.name) ?? false) { @@ -267,129 +277,228 @@ class HtmlParser extends StatelessWidget { ); if (render != null) { assert(render is InlineSpan || render is Widget); - return render is InlineSpan - ? render - : WidgetSpan( - child: ContainerSpan( - newContext: newContext, - style: tree.style, - shrinkWrap: context.parser.shrinkWrap, - child: render, - ), + return render is InlineSpan ? CustomTextSpan( + inlineSpanChild: render, + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + return StyledText( + style: newContext.style, + textSpan: TextSpan( + children: [render], + ), + renderContext: context, + ); + } + ) + ) : WidgetSpan( + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + return ContainerSpan( + newContext: newContext, + style: tree.style, + shrinkWrap: context.parser.shrinkWrap, + child: render, ); + }, + ), + ); } } //Return the correct InlineSpan based on the element type. if (tree.style?.display == Display.BLOCK) { return WidgetSpan( - child: ContainerSpan( - newContext: newContext, - style: tree.style, - shrinkWrap: context.parser.shrinkWrap, - children: tree.children - ?.map((tree) => parseTree(newContext, tree)) - ?.toList() ?? - [], - ), + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + return ContainerSpan( + newContext: newContext, + style: tree.style, + shrinkWrap: context.parser.shrinkWrap, + children: tree.children + ?.map((tree) => parseTree(newContext, tree)) + ?.toList() ?? + [], + ); + } + ) ); } else if (tree.style?.display == Display.LIST_ITEM) { return WidgetSpan( - child: ContainerSpan( - newContext: newContext, - style: tree.style, - shrinkWrap: context.parser.shrinkWrap, - child: Stack( - children: [ - if (tree.style?.listStylePosition == ListStylePosition.OUTSIDE || - tree.style?.listStylePosition == null) - PositionedDirectional( - width: 30, //TODO derive this from list padding. - start: 0, - child: Text('${newContext.style.markerContent}\t', - textAlign: TextAlign.right, - style: newContext.style.generateTextStyle()), - ), - Padding( - padding: EdgeInsetsDirectional.only( - start: 30), //TODO derive this from list padding. - child: StyledText( - textSpan: TextSpan( - text: (tree.style?.listStylePosition == - ListStylePosition.INSIDE) - ? '${newContext.style.markerContent}\t' - : null, - children: tree.children - ?.map((tree) => parseTree(newContext, tree)) - ?.toList() ?? - [], - style: newContext.style.generateTextStyle(), + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + return ContainerSpan( + newContext: newContext, + style: tree.style, + shrinkWrap: context.parser.shrinkWrap, + child: Stack( + children: [ + if (tree.style?.listStylePosition == ListStylePosition.OUTSIDE || + tree.style?.listStylePosition == null) + PositionedDirectional( + width: 30, //TODO derive this from list padding. + start: 0, + child: Text('${newContext.style.markerContent}\t', + textAlign: TextAlign.right, + style: newContext.style.generateTextStyle()), + ), + Padding( + padding: EdgeInsetsDirectional.only(start: 30), //TODO derive this from list padding. + child: StyledText( + textSpan: TextSpan( + text: (tree.style?.listStylePosition == + ListStylePosition.INSIDE) + ? '${newContext.style.markerContent}\t' + : null, + children: tree.children + ?.map((tree) => parseTree(newContext, tree)) + ?.toList() ?? + [], + style: newContext.style.generateTextStyle(), + ), + style: newContext.style, + renderContext: context, + ), + ) + ], ), - style: newContext.style, - renderContext: context, - ), - ) - ], - ), - ), + ); + } + ) ); } else if (tree is ReplacedElement) { if (tree is TextContentElement) { - return TextSpan(text: tree.text); + return CustomTextSpan( + inlineSpanChild: TextSpan(text: tree.text), + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + return StyledText( + style: newContext.style, + textSpan: TextSpan(text: tree.text), + renderContext: context, + ); + } + ) + ); } else { return WidgetSpan( alignment: tree.alignment, baseline: TextBaseline.alphabetic, - child: tree.toWidget(context), + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + return tree.toWidget(context); + } + ), ); } } else if (tree is InteractableElement) { - InlineSpan addTaps(InlineSpan childSpan, TextStyle childStyle) { - if (childSpan is TextSpan) { - return TextSpan( + void tapFunction() { + String key = buildContexts.keys.firstWhere((element) => element == tree.href.replaceFirst("#", ""), orElse: () => null); + if (tree.href.contains("#") && key != null) { + final context = buildContexts[key]; + final renderObject = context.findRenderObject(); + if (renderObject == null) return; + + final offsetToReveal = RenderAbstractViewport.of(renderObject) + ?.getOffsetToReveal(renderObject, 0.0) + ?.offset; + final position = Scrollable.of(scrollContext)?.position; + if (offsetToReveal == null || position == null) return; + + final alignment = (position.pixels > offsetToReveal) + ? 0.0 + : (position.pixels < offsetToReveal ? 1.0 : null); + if (alignment == null) return; + + position.ensureVisible( + renderObject, + alignment: alignment, + duration: const Duration(milliseconds: 100), + curve: Curves.easeIn, + ); + } else { + onLinkTap?.call(tree.href); + } + } + + Widget addWidgetTaps(InlineSpan childSpan, TextStyle childStyle) { + return RawGestureDetector( + gestures: { + MultipleTapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => MultipleTapGestureRecognizer(), (instance) { + instance..onTap = () => tapFunction(); + }, + ), + }, + child: (childSpan as WidgetSpan).child, + ); + } + + TextSpan addInlineSpanTaps(TextSpan childSpan, TextStyle childStyle) { + return TextSpan( text: childSpan.text, children: childSpan.children - ?.map((e) => addTaps(e, childStyle.merge(childSpan.style))) - ?.toList(), + ?.map((e) { + if (e is TextSpan) { + return addInlineSpanTaps(e, childStyle.merge(childSpan.style)); + } else if (e is CustomTextSpan && e.inlineSpanChild != null && e.inlineSpanChild is TextSpan) { + return addInlineSpanTaps(e.inlineSpanChild, childStyle.merge(childSpan.style)); + } + return null; + })?.toList() ?? null, style: newContext.style.generateTextStyle().merge( childSpan.style == null ? childStyle : childStyle.merge(childSpan.style)), - semanticsLabel: childSpan.semanticsLabel, - recognizer: TapGestureRecognizer() - ..onTap = () => onLinkTap?.call(tree.href), - ); - } else { - return WidgetSpan( - child: RawGestureDetector( - gestures: { - MultipleTapGestureRecognizer: - GestureRecognizerFactoryWithHandlers< - MultipleTapGestureRecognizer>( - () => MultipleTapGestureRecognizer(), - (instance) { - instance..onTap = () => onLinkTap?.call(tree.href); - }, - ), - }, - child: (childSpan as WidgetSpan).child, - ), - ); - } + recognizer: TapGestureRecognizer()..onTap = () => tapFunction(), + ); } return TextSpan( children: tree.children - .map((tree) => parseTree(newContext, tree)) - .map((childSpan) { - return addTaps(childSpan, - newContext.style.generateTextStyle().merge(childSpan.style)); - }).toList() ?? - [], + .map((tree) => parseTree(newContext, tree)) + .map((childSpan) { + return CustomTextSpan( + inlineSpanChild: (childSpan is CustomTextSpan && childSpan?.inlineSpanChild is TextSpan) || childSpan is TextSpan ? + addInlineSpanTaps(childSpan is CustomTextSpan ? childSpan.inlineSpanChild : childSpan, newContext.style.generateTextStyle().merge(childSpan.style)) : null, + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + final childStyle = newContext.style.generateTextStyle().merge(childSpan.style); + if (childSpan is CustomTextSpan && childSpan?.inlineSpanChild is TextSpan) { + return StyledText( + style: newContext.style.generateFromTextStyle(childStyle), + textSpan: addInlineSpanTaps(childSpan.inlineSpanChild, childStyle), + renderContext: context, + ); + } + if (childSpan is TextSpan) { + return StyledText( + style: newContext.style.generateFromTextStyle(childStyle), + textSpan: addInlineSpanTaps(childSpan, childStyle), + renderContext: context, + ); + } + return addWidgetTaps(childSpan, childStyle); + } + ) + ); + }).toList() ?? [], ); } else if (tree is LayoutElement) { return WidgetSpan( - child: tree.toWidget(context), + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + return tree.toWidget(context); + } + ), ); } else if (tree.style.verticalAlign != null && tree.style.verticalAlign != VerticalAlign.BASELINE) { @@ -406,27 +515,48 @@ class HtmlParser extends StatelessWidget { } //Requires special layout features not available in the TextStyle API. return WidgetSpan( - child: Transform.translate( - offset: Offset(0, verticalOffset), - child: StyledText( - textSpan: TextSpan( - style: newContext.style.generateTextStyle(), - children: tree.children - .map((tree) => parseTree(newContext, tree)) - .toList() ?? - [], - ), - style: newContext.style, - renderContext: context, - ), - ), + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + return Transform.translate( + offset: Offset(0, verticalOffset), + child: StyledText( + textSpan: TextSpan( + style: newContext.style.generateTextStyle(), + children: tree.children + .map((tree) => parseTree(newContext, tree)) + .toList() ?? [], + ), + style: newContext.style, + renderContext: context, + ), + ); + } + ) ); } else { ///[tree] is an inline element. - return TextSpan( - style: newContext.style.generateTextStyle(), - children: - tree.children.map((tree) => parseTree(newContext, tree)).toList(), + return CustomTextSpan( + style: newContext.style.generateTextStyle(), + inlineSpanChild: TextSpan( + style: newContext.style.generateTextStyle(), + children: + tree.children.map((tree) => parseTree(newContext, tree)).toList(), + ), + child: Builder( + builder: (BuildContext buildContext) { + if (tree.attributes['id'] != null) buildContexts[tree.attributes['id']] = buildContext; + return StyledText( + style: newContext.style.generateFromTextStyle(newContext.style.generateTextStyle()), + textSpan: TextSpan( + style: newContext.style.generateTextStyle(), + children: + tree.children.map((tree) => parseTree(newContext, tree)).toList(), + ), + renderContext: context, + ); + } + ) ); } } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index fddb87d88e..f0d0c9d6f7 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -1,4 +1,7 @@ +import 'dart:ui'; + import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; class Context { T data; @@ -43,3 +46,122 @@ class MultipleTapGestureRecognizer extends TapGestureRecognizer { } } } + +/// This class is a placeholder class so that the renderer can distinguish +/// between WidgetSpans and TextSpans +class CustomTextSpan extends WidgetSpan { + const CustomTextSpan({ + this.child, + this.inlineSpanChild, + PlaceholderAlignment alignment = PlaceholderAlignment.bottom, + TextBaseline baseline, + TextStyle style, + }) : assert(child != null), + assert(baseline != null || !( + identical(alignment, PlaceholderAlignment.aboveBaseline) || + identical(alignment, PlaceholderAlignment.belowBaseline) || + identical(alignment, PlaceholderAlignment.baseline) + )), + super( + alignment: alignment, + baseline: baseline, + style: style, + child: child, + ); + + final Widget child; + final InlineSpan inlineSpanChild; + + @override + void build(ParagraphBuilder builder, { double textScaleFactor = 1.0, List dimensions }) { + assert(debugAssertIsValid()); + assert(dimensions != null); + final bool hasStyle = style != null; + if (hasStyle) { + builder.pushStyle(style.getTextStyle(textScaleFactor: textScaleFactor)); + } + assert(builder.placeholderCount < dimensions.length); + final PlaceholderDimensions currentDimensions = dimensions[builder.placeholderCount]; + builder.addPlaceholder( + currentDimensions.size.width, + currentDimensions.size.height, + alignment, + scale: textScaleFactor, + baseline: currentDimensions.baseline, + baselineOffset: currentDimensions.baselineOffset, + ); + if (hasStyle) { + builder.pop(); + } + } + + @override + bool visitChildren(InlineSpanVisitor visitor) { + return visitor(this); + } + + @override + InlineSpan getSpanForPositionVisitor(TextPosition position, Accumulator offset) { + if (position.offset == offset.value) { + return this; + } + offset.increment(1); + return null; + } + + @override + int codeUnitAtVisitor(int index, Accumulator offset) { + return null; + } + + @override + RenderComparison compareTo(InlineSpan other) { + if (identical(this, other)) + return RenderComparison.identical; + if (other.runtimeType != runtimeType) + return RenderComparison.layout; + if ((style == null) != (other.style == null)) + return RenderComparison.layout; + final WidgetSpan typedOther = other as WidgetSpan; + if (child != typedOther.child || alignment != typedOther.alignment) { + return RenderComparison.layout; + } + RenderComparison result = RenderComparison.identical; + if (style != null) { + final RenderComparison candidate = style.compareTo(other.style); + if (candidate.index > result.index) + result = candidate; + if (result == RenderComparison.layout) + return result; + } + return result; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + if (super != other) + return false; + return other is WidgetSpan + && other.child == child + && other.alignment == alignment + && other.baseline == baseline; + } + + @override + int get hashCode => hashValues(super.hashCode, child, alignment, baseline); + + @override + InlineSpan getSpanForPosition(TextPosition position) { + assert(debugAssertIsValid()); + return null; + } + + @override + bool debugAssertIsValid() { + return true; + } +} \ No newline at end of file diff --git a/lib/style.dart b/lib/style.dart index 65eb39469e..e43b185c91 100644 --- a/lib/style.dart +++ b/lib/style.dart @@ -214,6 +214,26 @@ class Style { } } + Style generateFromTextStyle(TextStyle style) { + return Style( + backgroundColor: style.backgroundColor, + color: style.color, + fontFamily: style.fontFamily, + fontFeatureSettings: style.fontFeatures, + fontSize: FontSize(style.fontSize), + fontStyle: style.fontStyle, + fontWeight: style.fontWeight, + height: style.height, + lineHeight: LineHeight(style.height), + letterSpacing: style.letterSpacing, + textDecoration: style.decoration, + textDecorationColor: style.decorationColor, + textDecorationStyle: style.decorationStyle, + textDecorationThickness: style.decorationThickness, + textShadow: style.shadows, + ); + } + TextStyle generateTextStyle() { return TextStyle( backgroundColor: backgroundColor,