From d4d271d3eb25a53eecbb50f434a3a69e83ec36c8 Mon Sep 17 00:00:00 2001 From: pskink Date: Thu, 30 Oct 2025 12:06:04 +0100 Subject: [PATCH 1/2] multiple-stretch-areas initial commit --- example/images/multi.webp | Bin 0 -> 2872 bytes example/lib/basic.dart | 2 +- example/lib/main.dart | 81 ++++++----- example/lib/multi.dart | 41 ++++++ example/lib/shadows.dart | 86 ++++++------ example/pubspec.yaml | 1 + lib/nine_patch.dart | 7 +- lib/src/nine_patch.dart | 74 +++++++--- lib/src/nine_patch_aux.dart | 263 ++++++++++++++++++++++++++++-------- 9 files changed, 399 insertions(+), 156 deletions(-) create mode 100644 example/images/multi.webp create mode 100644 example/lib/multi.dart diff --git a/example/images/multi.webp b/example/images/multi.webp new file mode 100644 index 0000000000000000000000000000000000000000..52e0668cb53e4e8dad054893579c97cb7832f537 GIT binary patch literal 2872 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 literal 0 HcmV?d00001 diff --git a/example/lib/basic.dart b/example/lib/basic.dart index 1f4acf8..b998bf5 100644 --- a/example/lib/basic.dart +++ b/example/lib/basic.dart @@ -43,7 +43,7 @@ class Basic extends StatelessWidget { foregroundColor: WidgetStatePropertyAll(Colors.black), ), onPressed: () { - invalidateNinePatchCacheItem(const AssetImage('images/flag.9.webp')); + NinePatchCache.of(context)?.invalidateNinePatchCacheItem(const AssetImage('images/flag.9.webp')); }, child: const Text('NinePatch with "centerSlice" and "padding" embedded in image'), ), diff --git a/example/lib/main.dart b/example/lib/main.dart index 8a5f7e8..616957d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,46 +1,59 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:nine_patch/nine_patch.dart'; import 'animation.dart'; import 'basic.dart'; +import 'multi.dart'; import 'shadows.dart'; main() { - runApp(MaterialApp( - scrollBehavior: const MaterialScrollBehavior().copyWith(dragDevices: {PointerDeviceKind.mouse, PointerDeviceKind.touch}), - home: Scaffold( - body: Builder( - builder: (context) { - return ListView( - children: [ - ListTile( - title: const Text('basic usage'), - subtitle: const Text('Basic()'), - onTap: () { - final route = MaterialPageRoute(builder: (ctx) => Basic()); - Navigator.of(context).push(route); - }, - ), - ListTile( - title: const Text('text field shadows'), - subtitle: const Text('EditableTextShadows()'), - onTap: () { - final route = MaterialPageRoute(builder: (ctx) => EditableTextShadows()); - Navigator.of(context).push(route); - }, - ), - ListTile( - title: const Text('animated stuff'), - subtitle: const Text('Animated()'), - onTap: () { - final route = MaterialPageRoute(builder: (ctx) => Animated()); - Navigator.of(context).push(route); - }, - ), - ], - ); - } + runApp(NinePatchCache( + maximumSize: 10, + child: MaterialApp( + scrollBehavior: const MaterialScrollBehavior().copyWith(dragDevices: {PointerDeviceKind.mouse, PointerDeviceKind.touch}), + home: Scaffold( + body: Builder( + builder: (context) { + return ListView( + children: [ + ListTile( + title: const Text('basic usage'), + subtitle: const Text('Basic()'), + onTap: () { + final route = MaterialPageRoute(builder: (ctx) => Basic()); + Navigator.of(context).push(route); + }, + ), + ListTile( + title: const Text('multiple stretch lines'), + subtitle: const Text('Multi()'), + onTap: () { + final route = MaterialPageRoute(builder: (ctx) => Multi()); + Navigator.of(context).push(route); + }, + ), + ListTile( + title: const Text('text field shadows'), + subtitle: const Text('EditableTextShadows()'), + onTap: () { + final route = MaterialPageRoute(builder: (ctx) => EditableTextShadows()); + Navigator.of(context).push(route); + }, + ), + ListTile( + title: const Text('animated stuff'), + subtitle: const Text('Animated()'), + onTap: () { + final route = MaterialPageRoute(builder: (ctx) => Animated()); + Navigator.of(context).push(route); + }, + ), + ], + ); + } + ), ), ), )); diff --git a/example/lib/multi.dart b/example/lib/multi.dart new file mode 100644 index 0000000..24a5ac2 --- /dev/null +++ b/example/lib/multi.dart @@ -0,0 +1,41 @@ +// ignore_for_file: use_key_in_widget_constructors + +import 'package:flutter/material.dart'; + +import 'package:nine_patch/nine_patch.dart'; + +class Multi extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: SingleChildScrollView( + child: Center( + child: Column( + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: const NinePatch( + imageProvider: AssetImage('images/multi.webp'), + child: Center( + child: Text('this background is a single webp image that contains two horizontal stretch areas'), + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 128), + child: NinePatch( + colorFilter: ColorFilter.mode(Colors.lightGreenAccent.shade400, BlendMode.modulate), + imageProvider: const AssetImage('images/multi.webp'), + child: const Center( + child: Text('Incididunt aliquip elit non laboris aliquip do irure non non sint reprehenderit et exercitation elit.'), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/shadows.dart b/example/lib/shadows.dart index e266a70..a24bf82 100644 --- a/example/lib/shadows.dart +++ b/example/lib/shadows.dart @@ -28,54 +28,58 @@ class _EditableTextShadowsState extends State { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return Scaffold( - backgroundColor: Colors.green.shade200, appBar: AppBar(), - body: Column( - children: [ - const Card( - child: Padding( - padding: EdgeInsets.all(4), - child: Text('click any text field (or one of "focus traversal" buttons)', ), - ), - ), - Row( - children: [ - const Padding( - padding: EdgeInsets.only(left: 8), - child: Text('move focus:'), + body: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient(colors: [Colors.indigo.shade100, Colors.teal.shade200]), + ), + child: Column( + children: [ + const Card( + child: Padding( + padding: EdgeInsets.all(4), + child: Text('click any text field (or one of "focus traversal" buttons)', ), ), - IconButton(onPressed: () => _moveFocus(-1, 0), icon: const Icon(Icons.arrow_back)), - IconButton(onPressed: () => _moveFocus(1, -1), icon: const Icon(Icons.arrow_forward)), - ], - ), - const Divider(), - for (final (controller, focusNode) in editableTextData) - Padding( - padding: const EdgeInsets.only(top: 6, left: 6, right: 6), - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - color: focusNode.hasFocus? Colors.grey.shade200 : Colors.white, - child: AnimatedMultiNinePatch( - imageProvider: const AssetImage('images/shadows.webp'), - frameBuilder: horizontalFixedSizeFrameBuilder(6, const Size(18, 13)), - phase: focusNode.hasFocus? 0 : 1, + ), + Row( + children: [ + const Padding( + padding: EdgeInsets.only(left: 8), + child: Text('move focus:'), + ), + IconButton(onPressed: () => _moveFocus(-1, 0), icon: const Icon(Icons.arrow_back)), + IconButton(onPressed: () => _moveFocus(1, -1), icon: const Icon(Icons.arrow_forward)), + ], + ), + const Divider(), + for (final (controller, focusNode) in editableTextData) + Padding( + padding: const EdgeInsets.only(top: 6, left: 6, right: 6), + child: AnimatedContainer( duration: const Duration(milliseconds: 300), - debugLabel: 'shadows', - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: EditableText( - controller: controller, - focusNode: focusNode, - style: textTheme.headlineSmall!, - cursorColor: Colors.black54, - backgroundCursorColor: Colors.white, + color: focusNode.hasFocus? Colors.grey.shade200 : Colors.white, + child: AnimatedMultiNinePatch( + imageProvider: const AssetImage('images/shadows.webp'), + frameBuilder: horizontalFixedSizeFrameBuilder(6, const Size(18, 13)), + phase: focusNode.hasFocus? 0 : 1, + duration: const Duration(milliseconds: 300), + debugLabel: 'shadows', + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: EditableText( + controller: controller, + focusNode: focusNode, + style: textTheme.headlineSmall!, + cursorColor: Colors.black54, + backgroundCursorColor: Colors.white, + ), ), ), ), ), - ), - const SizedBox(height: 6), - ], + const SizedBox(height: 6), + ], + ), ), ); } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 13ad751..84b584d 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -70,6 +70,7 @@ flutter: - images/flag1.9.webp - images/btn_default_normal.9.webp - images/shadows.webp + - images/multi.webp # An image asset can refer to one or more resolution-specific "variants", see diff --git a/lib/nine_patch.dart b/lib/nine_patch.dart index 32b62b3..1fae132 100644 --- a/lib/nine_patch.dart +++ b/lib/nine_patch.dart @@ -2,13 +2,10 @@ export 'src/nine_patch.dart' show NinePatch, AnimatedNinePatch, AnimatedMultiNinePatch, - ninePatchCacheSize, horizontalFixedSizeFrameBuilder, verticalFixedSizeFrameBuilder, gridFixedSizeFrameBuilder, - invalidateNinePatchCache, - invalidateNinePatchCacheItem, - invalidateNinePatchCacheWhere, NinePatchBuilder, FrameBuilder, - NinePatchAnimatedBuilder; + NinePatchAnimatedBuilder, + NinePatchCache; diff --git a/lib/src/nine_patch.dart b/lib/src/nine_patch.dart index 5c2a7fc..61b18f3 100644 --- a/lib/src/nine_patch.dart +++ b/lib/src/nine_patch.dart @@ -8,14 +8,7 @@ import 'package:quiver/collection.dart'; part 'nine_patch_aux.dart'; -invalidateNinePatchCacheItem(ImageProvider key) => _lruMap.remove(key); -invalidateNinePatchCacheWhere(bool Function(ImageProvider key) test) => _lruMap.removeWhere((key, value) => test(key)); -invalidateNinePatchCache() => _lruMap.clear(); - -set ninePatchCacheSize(int maximumSize) => _lruMap.maximumSize = maximumSize; -int get ninePatchCacheSize => _lruMap.maximumSize; - -typedef NinePatchBuilder = Widget Function(ui.Image image, Rect centerSlice, EdgeInsets padding); +typedef NinePatchBuilder = Widget Function(ui.Image image, EdgeInsets padding); class NinePatch extends StatefulWidget { const NinePatch({ @@ -73,7 +66,7 @@ class _NinePatchState extends State with _NinePatchImageProviderState if (_imageRecord == null) return const SizedBox.shrink(); final ir = _imageRecord!; - final effectiveChild = widget.builder == null? widget.child : widget.builder!(ir.image, ir.centerSlice, ir.padding); + final effectiveChild = widget.builder == null? widget.child : widget.builder!(ir.image, ir.padding); return _buildNinePatch( imageRecord: ir, @@ -97,7 +90,8 @@ class _NinePatchState extends State with _NinePatchImageProviderState @override void updateImage(ImageInfo imageInfo, bool synchronousCall) async { - final imageRecordFuture = _cache.get(widget.imageProvider, ifAbsent: (k) async { + final cache = NinePatchCache.of(context)?._cache ?? _cache; + final imageRecordFuture = cache.get(widget.imageProvider, ifAbsent: (k) async { debugPrint('processing ${imageInfo.debugLabel}...'); final data = await imageInfo.image.toByteData(format: ui.ImageByteFormat.rawRgba); final rect = Offset.zero & Size(imageInfo.image.width.toDouble(), imageInfo.image.height.toDouble()); @@ -111,7 +105,7 @@ class _NinePatchState extends State with _NinePatchImageProviderState } if (widget.debugLabel != null) { - debugPrint('${widget.toString()} image: ${imageInfo.debugLabel}, centerSlice: ${imageRecord.centerSlice}, padding: ${imageRecord.padding}'); + debugPrint('${widget.toString()} image: ${imageInfo.debugLabel}, padding: ${imageRecord.padding}'); } setState(() { // Trigger a build whenever the image changes. @@ -120,7 +114,7 @@ class _NinePatchState extends State with _NinePatchImageProviderState } } -typedef NinePatchAnimatedBuilder = Widget Function(ui.Image image, Rect centerSlice, EdgeInsets padding, Animation animation); +typedef NinePatchAnimatedBuilder = Widget Function(ui.Image image, EdgeInsets padding, Animation animation); class AnimatedNinePatch extends ImplicitlyAnimatedWidget { const AnimatedNinePatch({ @@ -198,7 +192,7 @@ class _AnimatedNinePatchState extends AnimatedWidgetBaseState final color = _color?.evaluate(animation); final colorFilter = color != null? ColorFilter.mode(color, widget.blendMode) : null; final opacity = _opacity?.evaluate(animation) ?? 1; - final effectiveChild = widget.builder == null? widget.child : widget.builder!(ir.image, ir.centerSlice, ir.padding, animation); + final effectiveChild = widget.builder == null? widget.child : widget.builder!(ir.image, ir.padding, animation); return _buildNinePatch( imageRecord: ir, @@ -229,7 +223,8 @@ class _AnimatedNinePatchState extends AnimatedWidgetBaseState @override void updateImage(ImageInfo imageInfo, bool synchronousCall) async { - final imageRecordFuture = _cache.get(widget.imageProvider, ifAbsent: (k) async { + final cache = NinePatchCache.of(context)?._cache ?? _cache; + final imageRecordFuture = cache.get(widget.imageProvider, ifAbsent: (k) async { debugPrint('processing ${imageInfo.debugLabel}...'); final data = await imageInfo.image.toByteData(format: ui.ImageByteFormat.rawRgba); final rect = Offset.zero & Size(imageInfo.image.width.toDouble(), imageInfo.image.height.toDouble()); @@ -249,7 +244,7 @@ class _AnimatedNinePatchState extends AnimatedWidgetBaseState final imageRecord = (await imageRecordFuture)![0]; if (widget.debugLabel != null) { - debugPrint('${widget.toString()} image: ${imageInfo.debugLabel}, centerSlice: ${imageRecord.centerSlice}, padding: ${imageRecord.padding}'); + debugPrint('${widget.toString()} image: ${imageInfo.debugLabel}, padding: ${imageRecord.padding}'); } setState(() { // Trigger a build whenever the image changes. @@ -278,7 +273,9 @@ class _ImageRecordHolderTween extends Tween<_ImageRecordHolder?> { (_, _) => _ImageRecordHolder( imageRecord: ( image: t < 0.5? begin!.imageRecord!.image : end!.imageRecord!.image, - centerSlice: Rect.lerp(begin!.imageRecord!.centerSlice, end!.imageRecord!.centerSlice, t)!, + // TODO make a better lerping of horizontalRanges & verticalRanges + horizontalRanges: t < 0.5? begin!.imageRecord!.horizontalRanges : end!.imageRecord!.horizontalRanges, + verticalRanges: t < 0.5? begin!.imageRecord!.verticalRanges : end!.imageRecord!.verticalRanges, padding: EdgeInsets.lerp(begin!.imageRecord!.padding, end!.imageRecord!.padding, t)!, debugLabel: t < 0.5? begin!.imageRecord!.debugLabel : end!.imageRecord!.debugLabel, ), @@ -412,7 +409,8 @@ class _AnimatedMultiNinePatchState extends AnimatedWidgetBaseState>(maximumSize: 32); +final _cache = MapCache>(map: _lruMap); + +class NinePatchCache extends InheritedWidget with DiagnosticableTreeMixin { + NinePatchCache({ + super.key, + required super.child, + int? maximumSize, + }) : _lruMap = LruMap>(maximumSize: maximumSize ?? 32); + + final LruMap> _lruMap; + late final _cache = MapCache>(map: _lruMap); + + invalidateNinePatchCacheItem(ImageProvider key) => _lruMap.remove(key); + invalidateNinePatchCacheWhere(bool Function(ImageProvider key) test) => _lruMap.removeWhere((key, value) => test(key)); + invalidateNinePatchCache() => _lruMap.clear(); + + set ninePatchCacheSize(int maximumSize) => _lruMap.maximumSize = maximumSize; + int get ninePatchCacheSize => _lruMap.maximumSize; + + @override + bool updateShouldNotify(NinePatchCache oldWidget) => true; + + @override + List debugDescribeChildren() => [ + for (final MapEntry(:key, :value) in _lruMap.entries) + MessageProperty(key.toString(), '${value.first.image} ${value.first.debugLabel}') + ]; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('ninePatchCacheSize', ninePatchCacheSize)); + properties.add(IntProperty('length', _lruMap.length)); + } + + static NinePatchCache? of(BuildContext context) { + return context.getInheritedWidgetOfExactType(); + } +} diff --git a/lib/src/nine_patch_aux.dart b/lib/src/nine_patch_aux.dart index 98e2872..fd03edb 100644 --- a/lib/src/nine_patch_aux.dart +++ b/lib/src/nine_patch_aux.dart @@ -1,10 +1,14 @@ part of 'nine_patch.dart'; -typedef _Range = ({int start, int length}); -typedef _ImageRecord = ({ui.Image image, Rect centerSlice, EdgeInsets padding, String? debugLabel}); - -final _lruMap = LruMap>(maximumSize: 32); -final _cache = MapCache>(map: _lruMap); +typedef _Range = ({int start, int length, bool flexible}); +typedef _ImageRecord = ({ + ui.Image image, + _RangeList horizontalRanges, + _RangeList verticalRanges, + EdgeInsets padding, + String? debugLabel, +}); +typedef _RangeTester = int Function(int numFlexibleRanges); Widget _buildNinePatch({ required _ImageRecord imageRecord, @@ -17,8 +21,7 @@ Widget _buildNinePatch({ required String? debugLabel, }) { final painter = _NinePatchPainter( - image: imageRecord.image, - centerSlice: imageRecord.centerSlice, + imageRecord: imageRecord, colorFilter: colorFilter, opacity: opacity, fit: fit, @@ -35,16 +38,94 @@ Widget _buildNinePatch({ ), ); + final minWidth = imageRecord.horizontalRanges.fixedLength; + final minHeight = imageRecord.verticalRanges.fixedLength; // return customPaint; return ConstrainedBox( constraints: BoxConstraints( - minWidth: imageRecord.image.width - imageRecord.centerSlice.width + 1, - minHeight: imageRecord.image.height - imageRecord.centerSlice.height + 1, + minWidth: minWidth + 1, + minHeight: minHeight + 1, ), child: customPaint, ); } +class _RangeList { + _RangeList({ + required this.ranges, + }) : flexible = ranges.where((r) => r.flexible).toList(), + fixedLength = ranges.where((r) => !r.flexible).fold(0, (a, r) => a + r.length); + + final List<_Range> ranges; + final List<_Range> flexible; + final double fixedLength; + + List distribute(double targetPixels) { + final pixelCounters = [0, 0]; + for (final r in ranges) { + pixelCounters[!r.flexible? 0 : 1] += r.length; + } + final [fixedPixels, flexiblePixels] = pixelCounters; + assert(targetPixels - fixedPixels > 0); + + return _distribute(targetPixels, fixedPixels, flexiblePixels); + } + + List _distribute(double totalPixels, double fixedPixels, double flexiblePixels) { + // this will hold the final pixel values for each range. + final distributed = []; + // this will keep track of the fractional remainders for flexible ranges. + final remainders = <(int index, double remainder)>[]; + int i = 0; + for (final r in ranges) { + if (!r.flexible) { + // fixed ranges get their exact length. + distributed.add(r.length); + } else { + // flexible ranges get a proportional share of the remaining pixels. + final ideal = (totalPixels - fixedPixels) * r.length / flexiblePixels; + final assigned = ideal.floor(); + distributed.add(assigned); + // store the remainder for later redistribution. + remainders.add((i, ideal - assigned)); + } + i++; + } + + // calculate how many pixels are left undistributed due to flooring. + final rest = totalPixels - distributed.reduce((a, b) => a + b); + if (rest >= 1) { + // sort remainders descending by their fractional part, so the largest + // get the extra pixels first. + remainders.sort((a, b) => b.$2.compareTo(a.$2)); + + // distribute the remaining pixels to the flexible ranges with the largest remainders. + for (final (int index, double _) in remainders.take(rest.toInt())) { + distributed[index]++; + } + } + + return [ + for (final i in distributed) + i.toDouble() + ]; + } + + // List distribute(double totalPixels) { + // final pixelCounters = [0, 0]; + // for (final r in ranges) { + // pixelCounters[!r.flexible? 0 : 1] += r.length; + // } + // final [fixedPixels, flexiblePixels] = pixelCounters; + // assert(totalPixels - fixedPixels > 0); + + // return List.generate(ranges.length, (i) { + // final r = ranges[i]; + // return !r.flexible? r.length.toDouble() : (totalPixels - fixedPixels) * r.length / flexiblePixels; + // }); + // } +} + class _NinePatchDecoder { _NinePatchDecoder({ required this.imageProvider, @@ -55,29 +136,55 @@ class _NinePatchDecoder { List<_ImageRecord> processImage(ByteData data, ImageInfo imageInfo, List rects) { final width = imageInfo.image.width; + int testStretchRanges(int length) => length == 0? 1 : 0; + int testPaddingRanges(int length) => length > 1? 2 : 0; + final records = <_ImageRecord>[]; int frame = 1; for (final rect in rects) { - final centerH = _rangeH(data, width, rect, rect.top.toInt(), false, _pos('top', frame, rect))!; - final centerV = _rangeV(data, width, rect, rect.left.toInt(), false, _pos('left', frame, rect))!; - final paddingH = _rangeH(data, width, rect, rect.bottom.toInt() - 1, true, _pos('bottom', frame, rect)) ?? centerH; - final paddingV = _rangeV(data, width, rect, rect.right.toInt() - 1, true, _pos('right', frame, rect)) ?? centerV; - - final centerSlice = Rect.fromLTWH( - centerH.start.toDouble(), - centerV.start.toDouble(), - centerH.length.toDouble(), - centerV.length.toDouble(), + final horizontalRanges = _RangeList( + ranges: _rangeH(data, width, rect, rect.top.toInt(), testStretchRanges, _pos('top', frame, rect)) + ); + final verticalRanges = _RangeList( + ranges: _rangeV(data, width, rect, rect.left.toInt(), testStretchRanges, _pos('left', frame, rect)) ); + final horizontalPaddingRanges = _rangeH(data, width, rect, rect.bottom.toInt() - 1, testPaddingRanges, _pos('bottom', frame, rect)); + final verticalPaddingRanges = _rangeV(data, width, rect, rect.right.toInt() - 1, testPaddingRanges, _pos('right', frame, rect)); + + assert(horizontalRanges.flexible.isNotEmpty); + assert(verticalRanges.flexible.isNotEmpty); + final horizontalPaddingFlexibleRanges = horizontalPaddingRanges.where((r) => r.flexible); + assert(horizontalPaddingFlexibleRanges.length <= 1); + final verticalPaddingFlexibleRanges = verticalPaddingRanges.where((r) => r.flexible); + assert(verticalPaddingFlexibleRanges.length <= 1); + + final _Range firstHorizontalPaddingRange; + final _Range lastHorizontalPaddingRange; + final _Range firstVerticalPaddingRange; + final _Range lastVerticalPaddingRange; + if (horizontalPaddingFlexibleRanges.isNotEmpty) { + firstHorizontalPaddingRange = lastHorizontalPaddingRange = horizontalPaddingFlexibleRanges.first; + } else { + firstHorizontalPaddingRange = horizontalRanges.flexible.first; + lastHorizontalPaddingRange = horizontalRanges.flexible.last; + } + if (verticalPaddingFlexibleRanges.isNotEmpty) { + firstVerticalPaddingRange = lastVerticalPaddingRange = verticalPaddingFlexibleRanges.first; + } else { + firstVerticalPaddingRange = verticalRanges.flexible.first; + lastVerticalPaddingRange = verticalRanges.flexible.last; + } final padding = EdgeInsets.fromLTRB( - paddingH.start.toDouble(), - paddingV.start.toDouble(), - rect.width - (paddingH.start + paddingH.length + 2), - rect.height - (paddingV.start + paddingV.length + 2), + firstHorizontalPaddingRange.start.toDouble(), + firstVerticalPaddingRange.start.toDouble(), + rect.width - 2 - (lastHorizontalPaddingRange.start + lastHorizontalPaddingRange.length), + rect.height - 2 - (lastVerticalPaddingRange.start + lastVerticalPaddingRange.length), ); + records.add(( image: _cropImage(imageInfo.image, rect), - centerSlice: centerSlice, + horizontalRanges: horizontalRanges, + verticalRanges: verticalRanges, padding: padding, debugLabel: imageInfo.debugLabel, )); @@ -102,19 +209,19 @@ class _NinePatchDecoder { return data.getUint32(byteOffset) & 0xff; } - _Range? _rangeH(ByteData data, int width, Rect rect, int y, bool allowEmpty, String position) { + List<_Range> _rangeH(ByteData data, int width, Rect rect, int y, _RangeTester tester, String position) { final baseX = rect.left.toInt() + 1; final alphas = List.generate(rect.width.toInt() - 2, (x) => _alpha(data, width, baseX + x, y)); - return _range(alphas, allowEmpty, position); + return _range(alphas, tester, position); } - _Range? _rangeV(ByteData data, int width, Rect rect, int x, bool allowEmpty, String position) { + List<_Range> _rangeV(ByteData data, int width, Rect rect, int x, _RangeTester tester, String position) { final baseY = rect.top.toInt() + 1; final alphas = List.generate(rect.height.toInt() - 2, (y) => _alpha(data, width, x, baseY + y)); - return _range(alphas, allowEmpty, position); + return _range(alphas, tester, position); } - _Range? _range(List alphas, bool allowEmpty, String position) { + List<_Range> _range(List alphas, _RangeTester tester, String position) { if (alphas.any((alpha) => 0 < alpha && alpha < 255)) { final suspects = alphas .mapIndexed((index, alpha) => (index, alpha)) @@ -126,20 +233,19 @@ class _NinePatchDecoder { int start = 0; List<_Range> rangeRecords = []; for (final range in ranges) { - if (range[0] != 0) { - rangeRecords.add((start: start, length: range.length)); - } + rangeRecords.add((start: start, length: range.length, flexible: range[0] != 0)); start += range.length; } - if (rangeRecords.length > 1) { - final rangesStr = rangeRecords.map((r) => '${r.start}..${r.start + r.length - 1}').join(' '); - throw 'multiple opaque ranges along $position\n${_decorate(alphas)}\nfound ranges: $rangesStr'; - } - if (!allowEmpty && rangeRecords.isEmpty) { - throw 'no opaque range along $position'; + + int numFlexibleRanges = rangeRecords.where((r) => r.flexible).length; + switch (tester(numFlexibleRanges)) { + case 1: + throw 'no opaque range along $position'; + case 2: + final rangesStr = rangeRecords.map((r) => '${r.start}..${r.start + r.length - 1}').join(' '); + throw 'multiple opaque ranges along $position\n${_decorate(alphas)}\nfound ranges: $rangesStr'; } - // print('$alphas $rangeRecords'); - return rangeRecords.firstOrNull; + return rangeRecords; } String _pos(String pos, int frame, Rect rect) => 'the $pos edge of frame #$frame ($rect) of $imageProvider'; @@ -149,8 +255,7 @@ class _NinePatchDecoder { class _NinePatchPainter extends CustomPainter { _NinePatchPainter({ - required this.image, - required this.centerSlice, + required this.imageRecord, required this.colorFilter, required this.opacity, required this.fit, @@ -158,8 +263,7 @@ class _NinePatchPainter extends CustomPainter { this.debugLabel, }); - final ui.Image image; - final Rect centerSlice; + final _ImageRecord imageRecord; final double opacity; final ColorFilter? colorFilter; final BoxFit? fit; @@ -170,19 +274,21 @@ class _NinePatchPainter extends CustomPainter { void paint(Canvas canvas, Size size) { // print('paint $image'); - final widthFits = size.width > image.width - centerSlice.width; - final heightFits = size.height > image.height - centerSlice.height; + final image = imageRecord.image; + final minWidth = imageRecord.horizontalRanges.fixedLength; + final minHeight = imageRecord.verticalRanges.fixedLength; + final widthFits = size.width > minWidth; + final heightFits = size.height > minHeight; if (notifier.value != size && (!widthFits || !heightFits)) { notifier.value = size; final buffer = StringBuffer('''$debugLabel current size is not big enough to paint the image current size: $size image: $image - centerSlice: $centerSlice, ${centerSlice.size} reason(s): '''); - if (!widthFits) buffer.writeln(' width does not fit because ${size.width} <= ${image.width} - ${centerSlice.width}'); - if (!heightFits) buffer.writeln(' height does not fit because ${size.height} <= ${image.height} - ${centerSlice.height}'); + if (!widthFits) buffer.writeln(' width does not fit because ${size.width} <= $minWidth'); + if (!heightFits) buffer.writeln(' height does not fit because ${size.height} <= $minHeight'); FlutterError.reportError( FlutterErrorDetails( exception: buffer.toString(), @@ -190,15 +296,56 @@ reason(s): ); } - paintImage( - canvas: canvas, - rect: Offset.zero & size, - image: image, - opacity: opacity, - colorFilter: colorFilter, - fit: fit, - centerSlice: widthFits && heightFits? centerSlice : null, - ); + final ir = imageRecord; + if (ir.horizontalRanges.flexible.length == 1 && ir.verticalRanges.flexible.length == 1) { + final centerSlice = widthFits && heightFits? + Rect.fromLTWH( + ir.horizontalRanges.flexible.first.start.toDouble(), + ir.verticalRanges.flexible.first.start.toDouble(), + ir.horizontalRanges.flexible.first.length.toDouble(), + ir.verticalRanges.flexible.first.length.toDouble(), + ) : null; + paintImage( + canvas: canvas, + rect: Offset.zero & size, + image: image, + opacity: opacity, + colorFilter: colorFilter, + fit: fit, + centerSlice: centerSlice, + ); + } else { + final Paint paint = Paint() + ..color = Color.fromRGBO(0, 0, 0, clampDouble(opacity, 0.0, 1.0)) + ..colorFilter = colorFilter; + final distributedHorizontalRanges = ir.horizontalRanges.distribute(size.width); + final distributedVerticalRanges = ir.verticalRanges.distribute(size.height); + double x = 0; + double distributedX = 0; + for (int ix = 0; ix < distributedHorizontalRanges.length; ix++) { + double y = 0; + double distributedY = 0; + for (int iy = 0; iy < distributedVerticalRanges.length; iy++) { + final src = Rect.fromLTWH( + x, + y, + ir.horizontalRanges.ranges[ix].length.toDouble(), + ir.verticalRanges.ranges[iy].length.toDouble(), + ); + final dst = Rect.fromLTWH( + distributedX, + distributedY, + distributedHorizontalRanges[ix], + distributedVerticalRanges[iy], + ); + canvas.drawImageRect(image, src, dst, paint); + y += ir.verticalRanges.ranges[iy].length; + distributedY += distributedVerticalRanges[iy]; + } + x += ir.horizontalRanges.ranges[ix].length; + distributedX += distributedHorizontalRanges[ix]; + } + } } @override From fb9a55c34117cba34d49607e658f9ef4e8f02937 Mon Sep 17 00:00:00 2001 From: pskink Date: Mon, 3 Nov 2025 16:55:33 +0100 Subject: [PATCH 2/2] custom-decoration initial commit --- example/lib/multi.dart | 24 ++++++++ lib/nine_patch.dart | 2 + lib/src/nine_patch.dart | 92 ++++++++++++++++++++++++++++ lib/src/nine_patch_aux.dart | 116 ++++++++++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+) diff --git a/example/lib/multi.dart b/example/lib/multi.dart index 24a5ac2..4de69f6 100644 --- a/example/lib/multi.dart +++ b/example/lib/multi.dart @@ -32,6 +32,30 @@ class Multi extends StatelessWidget { ), ), ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 225), + child: Container( + decoration: NinePatchDecoration( + image: const NinePatchDecorationImage( + image: AssetImage('images/multi.webp') + ), + context: context, + padding: const EdgeInsets.only(top: 8) + const EdgeInsets.all(10), + ), + child: const Center( + child: Text.rich(TextSpan( + children: [ + TextSpan(text: 'this is a simple '), + TextSpan(text: 'Container ', style: TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: 'using '), + TextSpan(text: 'NinePatchDecoration', style: TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: ', note that you have to provide the content padding since it cannot be synchronously read from the '), + TextSpan(text: 'ImageProvider', style: TextStyle(fontWeight: FontWeight.bold)), + ], + )), + ), + ), + ), ], ), ), diff --git a/lib/nine_patch.dart b/lib/nine_patch.dart index 1fae132..7024ca9 100644 --- a/lib/nine_patch.dart +++ b/lib/nine_patch.dart @@ -2,6 +2,8 @@ export 'src/nine_patch.dart' show NinePatch, AnimatedNinePatch, AnimatedMultiNinePatch, + NinePatchDecoration, + NinePatchDecorationImage, horizontalFixedSizeFrameBuilder, verticalFixedSizeFrameBuilder, gridFixedSizeFrameBuilder, diff --git a/lib/src/nine_patch.dart b/lib/src/nine_patch.dart index 61b18f3..ab02e98 100644 --- a/lib/src/nine_patch.dart +++ b/lib/src/nine_patch.dart @@ -431,6 +431,98 @@ class _AnimatedMultiNinePatchState extends AnimatedWidgetBaseState Object.hash( + image, + colorFilter, + opacity, + ); + + @override + String toString() { + final List properties = [ + '$image', + if (colorFilter != null) '$colorFilter', + 'opacity ${opacity.toStringAsFixed(1)}', + ]; + return '${objectRuntimeType(this, 'DecorationImage')}(${properties.join(", ")})'; + } +} + // fallback cache final _lruMap = LruMap>(maximumSize: 32); final _cache = MapCache>(map: _lruMap); diff --git a/lib/src/nine_patch_aux.dart b/lib/src/nine_patch_aux.dart index fd03edb..1ec9ba5 100644 --- a/lib/src/nine_patch_aux.dart +++ b/lib/src/nine_patch_aux.dart @@ -413,3 +413,119 @@ mixin _NinePatchImageProviderStateMixin on State { } } +class _NinePatchBoxPainter extends BoxPainter { + _NinePatchBoxPainter(this._decoration, super.onChanged); + + final NinePatchDecoration _decoration; + _NinePatchDecorationImagePainter? _imagePainter; + + @override + void dispose() { + _imagePainter?.dispose(); + super.dispose(); + } + + @override + void paint(ui.Canvas canvas, ui.Offset offset, ImageConfiguration configuration) { + _imagePainter ??= _decoration.image.createPainter(_decoration.context, onChanged!); + final Rect rect = offset & configuration.size!; + _imagePainter!.paint(canvas, rect, configuration); + } +} + +class _NinePatchDecorationImagePainter { + _NinePatchDecorationImagePainter(this._details, this._context, this._onChanged) { + assert(debugMaybeDispatchCreated('painting', '_DecorationImagePainter', this)); + } + + final NinePatchDecorationImage _details; + final BuildContext _context; + final VoidCallback _onChanged; + + ImageStream? _imageStream; + ImageInfo? _image; + CustomPainter? _ninePatchPainter; + final _notifier = ValueNotifier(Size.zero); + + void paint( + Canvas canvas, + Rect rect, + ImageConfiguration configuration, { + double blend = 1.0, + BlendMode blendMode = BlendMode.srcOver, + }) { + final ImageStream newImageStream = _details.image.resolve(configuration); + if (newImageStream.key != _imageStream?.key) { + final ImageStreamListener listener = ImageStreamListener( + _handleImage, + onError: _details.onError, + ); + _imageStream?.removeListener(listener); + _imageStream = newImageStream; + _imageStream!.addListener(listener); + } + if (_image == null) { + return; + } + + final cache = NinePatchCache.of(_context)?._cache ?? _cache; + final img = _image!.image; + final imageRecordFuture = cache.get(_details.image, ifAbsent: (k) async { + debugPrint('processing ${_details.debugLabel ?? ''}...'); + final data = await img.toByteData(format: ui.ImageByteFormat.rawRgba); + final rect = Offset.zero & Size(img.width.toDouble(), img.height.toDouble()); + final decoder = _NinePatchDecoder( + imageProvider: _details.image, + ); + return decoder.processImage(data!, _image!, [rect]); + }); + + imageRecordFuture.then((irs) { + if (_ninePatchPainter == null) { + _ninePatchPainter = _NinePatchPainter( + imageRecord: irs!.first, + colorFilter: _details.colorFilter, + opacity: _details.opacity, + fit: BoxFit.none, + notifier: _notifier, + ); + _onChanged(); + } + }); + + if (_ninePatchPainter != null) { + canvas + ..save() + ..translate(rect.left, rect.top); + _ninePatchPainter!.paint(canvas, rect.size); + canvas.restore(); + } + } + + void _handleImage(ImageInfo value, bool synchronousCall) { + if (_image == value) { + return; + } + if (_image != null && _image!.isCloneOf(value)) { + value.dispose(); + return; + } + _image?.dispose(); + _image = value; + if (!synchronousCall) { + _onChanged(); + } + } + + void dispose() { + assert(debugMaybeDispatchDisposed(this)); + _imageStream?.removeListener(ImageStreamListener(_handleImage, onError: _details.onError)); + _image?.dispose(); + _image = null; + } + + @override + String toString() { + return '${objectRuntimeType(this, 'DecorationImagePainter')}(stream: $_imageStream, image: $_image) for $_details'; + } +}