Last active
February 26, 2024 09:07
-
-
Save pskink/d05d4f5124293597ad1e77f957b2e6d0 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:ui' as ui; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:collection/collection.dart'; | |
import 'package:quiver/cache.dart'; | |
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); | |
class NinePatch extends StatefulWidget { | |
const NinePatch({ | |
super.key, | |
required Widget this.child, | |
required this.imageProvider, | |
this.colorFilter, | |
this.opacity = 1, | |
this.fit, | |
this.position = DecorationPosition.background, | |
this.debugLabel, | |
}) : builder = null; | |
const NinePatch.builder({ | |
super.key, | |
required NinePatchBuilder this.builder, | |
required this.imageProvider, | |
this.colorFilter, | |
this.opacity = 1, | |
this.fit, | |
this.position = DecorationPosition.background, | |
this.debugLabel, | |
}) : child = null; | |
final Widget? child; | |
final NinePatchBuilder? builder; | |
final ImageProvider imageProvider; | |
final ColorFilter? colorFilter; | |
final double opacity; | |
final BoxFit? fit; | |
final DecorationPosition position; | |
final String? debugLabel; | |
@override | |
State<NinePatch> createState() => _NinePatchState(); | |
@override | |
void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
super.debugFillProperties(properties); | |
properties.add(DiagnosticsProperty<ImageProvider>('imageProvider', imageProvider)); | |
properties.add(DiagnosticsProperty<ColorFilter>('colorFilter', colorFilter, defaultValue: null)); | |
properties.add(DoubleProperty('opacity', opacity, defaultValue: 1)); | |
properties.add(EnumProperty<BoxFit>('fit', fit, defaultValue: null)); | |
properties.add(EnumProperty<DecorationPosition>('position', position, defaultValue: DecorationPosition.background)); | |
properties.add(StringProperty('debugLabel', debugLabel, defaultValue: null)); | |
} | |
} | |
class _NinePatchState extends State<NinePatch> with _NinePatchImageProviderStateMixin { | |
_ImageRecord? _imageRecord; | |
final _notifier = ValueNotifier(Size.zero); | |
@override | |
Widget build(BuildContext context) { | |
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); | |
return _buildNinePatch( | |
imageRecord: ir, | |
colorFilter: widget.colorFilter, | |
opacity: widget.opacity, | |
fit: widget.fit, | |
position: widget.position, | |
notifier: _notifier, | |
child: effectiveChild, | |
debugLabel: '${widget.debugLabel ?? '<empty debugLabel>'} ${ir.debugLabel}', | |
); | |
} | |
@override | |
ImageProvider<Object> get imageProvider => widget.imageProvider; | |
@override | |
bool didProviderChange(NinePatch widget, NinePatch oldWidget) { | |
return widget.imageProvider != oldWidget.imageProvider; | |
} | |
@override | |
void updateImage(ImageInfo imageInfo, bool synchronousCall) async { | |
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()); | |
return decoder.processImage(data!, imageInfo, [rect]); | |
}); | |
final oldImageProvider = widget.imageProvider; | |
final imageRecord = (await imageRecordFuture)![0]; | |
if (oldImageProvider != widget.imageProvider) { | |
debugPrint('skipping update for ${widget.toString()}, reason: $oldImageProvider != this widget imageProvider'); | |
return; | |
} | |
if (widget.debugLabel != null) { | |
debugPrint('${widget.toString()} image: ${imageInfo.debugLabel}, centerSlice: ${imageRecord.centerSlice}, padding: ${imageRecord.padding}'); | |
} | |
setState(() { | |
// Trigger a build whenever the image changes. | |
_imageRecord = imageRecord; | |
}); | |
} | |
} | |
typedef NinePatchAnimatedBuilder = Widget Function(ui.Image image, Rect centerSlice, EdgeInsets padding, Animation<double> animation); | |
class AnimatedNinePatch extends ImplicitlyAnimatedWidget { | |
const AnimatedNinePatch({ | |
super.key, | |
required this.child, | |
required this.imageProvider, | |
this.color, | |
this.blendMode = BlendMode.srcIn, | |
this.opacity = 1, | |
this.fit, | |
this.position = DecorationPosition.background, | |
this.debugLabel, | |
super.curve, | |
required super.duration, | |
super.onEnd, | |
}) : builder = null; | |
const AnimatedNinePatch.builder({ | |
super.key, | |
required NinePatchAnimatedBuilder this.builder, | |
required this.imageProvider, | |
this.color, | |
this.blendMode = BlendMode.srcIn, | |
this.opacity = 1, | |
this.fit, | |
this.position = DecorationPosition.background, | |
this.debugLabel, | |
super.curve, | |
required super.duration, | |
super.onEnd, | |
}) : child = null; | |
final Widget? child; | |
final NinePatchAnimatedBuilder? builder; | |
final ImageProvider imageProvider; | |
final Color? color; | |
final BlendMode blendMode; | |
final double opacity; | |
final BoxFit? fit; | |
final DecorationPosition position; | |
final String? debugLabel; | |
@override | |
ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState() { | |
return _AnimatedNinePatchState(); | |
} | |
@override | |
void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
super.debugFillProperties(properties); | |
properties.add(DiagnosticsProperty<ImageProvider>('imageProvider', imageProvider)); | |
properties.add(ColorProperty('color', color, defaultValue: null)); | |
properties.add(DiagnosticsProperty<BlendMode>('blendMode', blendMode, defaultValue: BlendMode.srcIn)); | |
properties.add(DoubleProperty('opacity', opacity, defaultValue: 1)); | |
properties.add(EnumProperty<BoxFit>('fit', fit, defaultValue: null)); | |
properties.add(EnumProperty<DecorationPosition>('position', position, defaultValue: DecorationPosition.background)); | |
properties.add(StringProperty('debugLabel', debugLabel, defaultValue: null)); | |
} | |
} | |
class _AnimatedNinePatchState extends AnimatedWidgetBaseState<AnimatedNinePatch> | |
with _NinePatchImageProviderStateMixin { | |
ColorTween? _color; | |
Tween<double>? _opacity; | |
_ImageRecordHolderTween? _imageRecordHolder; | |
final _notifier = ValueNotifier(Size.zero); | |
@override | |
Widget build(BuildContext context) { | |
final holder = _imageRecordHolder?.evaluate(animation); | |
if (holder == null) return const SizedBox.shrink(); | |
final ir = holder.imageRecord!; | |
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); | |
return _buildNinePatch( | |
imageRecord: ir, | |
colorFilter: colorFilter, | |
opacity: opacity, | |
fit: widget.fit, | |
position: widget.position, | |
notifier: _notifier, | |
child: effectiveChild, | |
debugLabel: '${widget.debugLabel ?? '<empty debugLabel>'} ${ir.debugLabel}', | |
); | |
} | |
@override | |
void forEachTween(TweenVisitor<dynamic> visitor) { | |
_color = visitor(_color, widget.color, (dynamic value) => ColorTween(begin: value as Color)) as ColorTween?; | |
_opacity = visitor(_opacity, widget.opacity, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?; | |
_imageRecordHolder = visitor(_imageRecordHolder, _ImageRecordHolder(), (dynamic value) => _ImageRecordHolderTween(begin: value as _ImageRecordHolder)) as _ImageRecordHolderTween?; | |
} | |
@override | |
ImageProvider<Object> get imageProvider => widget.imageProvider; | |
@override | |
bool didProviderChange(AnimatedNinePatch widget, AnimatedNinePatch oldWidget) { | |
return widget.imageProvider != oldWidget.imageProvider; | |
} | |
@override | |
void updateImage(ImageInfo imageInfo, bool synchronousCall) async { | |
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()); | |
return decoder.processImage(data!, imageInfo, [rect]); | |
}); | |
/* | |
TODO do we need this check (the same as in NinePatch)? | |
final oldImageProvider = widget.imageProvider; | |
final imageRecord = (await imageRecordFuture)!; | |
if (oldImageProvider != widget.imageProvider) { | |
debugPrint('skipping update for ${widget.toString()}, reason: $oldImageProvider != this widget imageProvider'); | |
return; | |
} | |
*/ | |
final imageRecord = (await imageRecordFuture)![0]; | |
if (widget.debugLabel != null) { | |
debugPrint('${widget.toString()} image: ${imageInfo.debugLabel}, centerSlice: ${imageRecord.centerSlice}, padding: ${imageRecord.padding}'); | |
} | |
setState(() { | |
// Trigger a build whenever the image changes. | |
// _imageRecord = imageRecord; | |
_imageRecordHolder!.end!.imageRecord = imageRecord; | |
}); | |
} | |
} | |
class _ImageRecordHolder { | |
_ImageRecordHolder({ | |
this.imageRecord, | |
}); | |
_ImageRecord? imageRecord; | |
} | |
class _ImageRecordHolderTween extends Tween<_ImageRecordHolder?> { | |
_ImageRecordHolderTween({ super.begin, super.end }); | |
@override | |
_ImageRecordHolder? transform(double t) { | |
return switch ((begin?.imageRecord, end?.imageRecord)) { | |
(null, null) => null, | |
(_, null) => begin, | |
(_, _) => _ImageRecordHolder( | |
imageRecord: ( | |
image: t < 0.5? begin!.imageRecord!.image : end!.imageRecord!.image, | |
centerSlice: Rect.lerp(begin!.imageRecord!.centerSlice, end!.imageRecord!.centerSlice, t)!, | |
padding: EdgeInsets.lerp(begin!.imageRecord!.padding, end!.imageRecord!.padding, t)!, | |
debugLabel: t < 0.5? begin!.imageRecord!.debugLabel : end!.imageRecord!.debugLabel, | |
), | |
), | |
}; | |
} | |
} | |
typedef FrameBuilder = (int frameCount, Rect Function(int frameNumber) rectBuilder); | |
FrameBuilder horizontalFixedSizeFrameBuilder(int frameCount, Size size) { | |
return (frameCount, (int i) => Rect.fromLTWH(i * size.width, 0, size.width, size.height)); | |
} | |
FrameBuilder verticalFixedSizeFrameBuilder(int frameCount, Size size) { | |
return (frameCount, (int i) => Rect.fromLTWH(0, i * size.height, size.width, size.height)); | |
} | |
FrameBuilder gridFixedSizeFrameBuilder(int frameCount, int numColumns, Size size) { | |
return (frameCount, (int i) { | |
final col = i % numColumns; | |
final row = i ~/ numColumns; | |
return Rect.fromLTWH(col * size.width, row * size.height, size.width, size.height); | |
}); | |
} | |
class AnimatedMultiNinePatch extends ImplicitlyAnimatedWidget { | |
const AnimatedMultiNinePatch({ | |
super.key, | |
required this.child, | |
required this.imageProvider, | |
required this.frameBuilder, | |
this.phase, | |
this.index, | |
this.color, | |
this.blendMode = BlendMode.srcIn, | |
this.opacity = 1, | |
this.fit, | |
this.position = DecorationPosition.background, | |
this.debugLabel, | |
super.curve, | |
required super.duration, | |
super.onEnd, | |
}) : assert(phase == null || index == null, 'cannot use both phase and index'); | |
final Widget child; | |
final ImageProvider imageProvider; | |
final FrameBuilder frameBuilder; | |
final double? phase; | |
final int? index; | |
final Color? color; | |
final BlendMode blendMode; | |
final double opacity; | |
final BoxFit? fit; | |
final DecorationPosition position; | |
final String? debugLabel; | |
@override | |
ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState() { | |
return _AnimatedMultiNinePatchState(); | |
} | |
@override | |
void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
super.debugFillProperties(properties); | |
properties.add(DiagnosticsProperty<ImageProvider>('imageProvider', imageProvider)); | |
properties.add(DoubleProperty('phase', phase)); | |
properties.add(IntProperty('index', index)); | |
properties.add(ColorProperty('color', color, defaultValue: null)); | |
properties.add(DiagnosticsProperty<BlendMode>('blendMode', blendMode, defaultValue: BlendMode.srcIn)); | |
properties.add(DoubleProperty('opacity', opacity, defaultValue: 1)); | |
properties.add(EnumProperty<BoxFit>('fit', fit, defaultValue: null)); | |
properties.add(EnumProperty<DecorationPosition>('position', position, defaultValue: DecorationPosition.background)); | |
properties.add(StringProperty('debugLabel', debugLabel, defaultValue: null)); | |
} | |
} | |
class _AnimatedMultiNinePatchState extends AnimatedWidgetBaseState<AnimatedMultiNinePatch> | |
with _NinePatchImageProviderStateMixin { | |
ColorTween? _color; | |
Tween<double>? _opacity; | |
Tween<double>? _phase; | |
IntTween? _index; | |
List<_ImageRecord>? _imageRecords; | |
final _notifier = ValueNotifier(Size.zero); | |
@override | |
Widget build(BuildContext context) { | |
if (_imageRecords == null) return const SizedBox.shrink(); | |
final color = _color?.evaluate(animation); | |
final colorFilter = color != null? ColorFilter.mode(color, widget.blendMode) : null; | |
final opacity = _opacity?.evaluate(animation) ?? 1; | |
assert(widget.phase == null || 0 <= widget.phase! && widget.phase! <= 1); | |
assert(widget.index == null || 0 <= widget.index! && widget.index! < widget.frameBuilder.$1); | |
final phase = _phase?.evaluate(animation); | |
final index = _index?.evaluate(animation); | |
final idx = index ?? ((phase ?? 0) * (_imageRecords!.length - 1)).round(); | |
// TODO add lerps of _ImageRecord.centerSlice and _ImageRecord.padding ??? | |
return _buildNinePatch( | |
imageRecord: _imageRecords![idx], | |
colorFilter: colorFilter, | |
opacity: opacity, | |
fit: widget.fit, | |
position: widget.position, | |
notifier: _notifier, | |
debugLabel: widget.debugLabel != null? '${widget.debugLabel}[$idx]' : null, | |
child: widget.child, | |
); | |
} | |
@override | |
void forEachTween(TweenVisitor<dynamic> visitor) { | |
_color = visitor(_color, widget.color, (dynamic value) => ColorTween(begin: value as Color)) as ColorTween?; | |
_opacity = visitor(_opacity, widget.opacity, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?; | |
_phase = visitor(_phase, widget.phase, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?; | |
_index = visitor(_index, widget.index, (dynamic value) => IntTween(begin: value as int)) as IntTween?; | |
} | |
@override | |
ImageProvider<Object> get imageProvider => widget.imageProvider; | |
@override | |
bool didProviderChange(AnimatedMultiNinePatch widget, AnimatedMultiNinePatch oldWidget) { | |
return widget.imageProvider != oldWidget.imageProvider; | |
} | |
@override | |
void updateImage(ImageInfo imageInfo, bool synchronousCall) async { | |
final imageRecordFuture = _cache.get(widget.imageProvider, ifAbsent: (k) async { | |
debugPrint('processing ${imageInfo.debugLabel}...'); | |
final data = await imageInfo.image.toByteData(format: ui.ImageByteFormat.rawRgba); | |
final (frameCount, rectBuilder) = widget.frameBuilder; | |
final rects = List.generate(frameCount, rectBuilder); | |
return decoder.processImage(data!, imageInfo, rects); | |
}); | |
final oldImageProvider = widget.imageProvider; | |
final imageRecords = (await imageRecordFuture)!; | |
if (oldImageProvider != widget.imageProvider) { | |
debugPrint('skipping update for ${widget.toString()}, reason: $oldImageProvider != this widget imageProvider'); | |
return; | |
} | |
setState(() { | |
// Trigger a build whenever the image changes. | |
_imageRecords = imageRecords; | |
}); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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<ImageProvider, List<_ImageRecord>>(maximumSize: 32); | |
final _cache = MapCache<ImageProvider, List<_ImageRecord>>(map: _lruMap); | |
Widget _buildNinePatch({ | |
required _ImageRecord imageRecord, | |
required ColorFilter? colorFilter, | |
required double opacity, | |
required BoxFit? fit, | |
required DecorationPosition position, | |
required ValueNotifier<Size> notifier, | |
required Widget? child, | |
required String? debugLabel, | |
}) { | |
final painter = _NinePatchPainter( | |
image: imageRecord.image, | |
centerSlice: imageRecord.centerSlice, | |
colorFilter: colorFilter, | |
opacity: opacity, | |
fit: fit, | |
notifier: notifier, | |
debugLabel: debugLabel, | |
); | |
final customPaint = CustomPaint( | |
painter: position == DecorationPosition.background? painter : null, | |
foregroundPainter: position == DecorationPosition.foreground? painter : null, | |
child: Padding( | |
padding: imageRecord.padding, | |
child: child, | |
), | |
); | |
// return customPaint; | |
return ConstrainedBox( | |
constraints: BoxConstraints( | |
minWidth: imageRecord.image.width - imageRecord.centerSlice.width + 1, | |
minHeight: imageRecord.image.height - imageRecord.centerSlice.height + 1, | |
), | |
child: customPaint, | |
); | |
} | |
class _NinePatchDecoder { | |
_NinePatchDecoder({ | |
required this.imageProvider, | |
}); | |
final ImageProvider imageProvider; | |
List<_ImageRecord> processImage(ByteData data, ImageInfo imageInfo, List<Rect> rects) { | |
final width = imageInfo.image.width; | |
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 padding = EdgeInsets.fromLTRB( | |
paddingH.start.toDouble(), | |
paddingV.start.toDouble(), | |
rect.width - (paddingH.start + paddingH.length + 2), | |
rect.height - (paddingV.start + paddingV.length + 2), | |
); | |
records.add(( | |
image: _cropImage(imageInfo.image, rect), | |
centerSlice: centerSlice, | |
padding: padding, | |
debugLabel: imageInfo.debugLabel, | |
)); | |
frame++; | |
} | |
imageInfo.dispose(); | |
return records; | |
} | |
ui.Image _cropImage(ui.Image image, Rect rect) { | |
final recorder = ui.PictureRecorder(); | |
final canvas = Canvas(recorder); | |
rect = rect.deflate(1); | |
canvas.drawImageRect(image, rect, Offset.zero & rect.size, Paint()); | |
return recorder | |
.endRecording() | |
.toImageSync(rect.width.toInt(), rect.height.toInt()); | |
} | |
int _alpha(ByteData data, int width, int x, int y) { | |
final byteOffset = 4 * (x + (y * width)); | |
return data.getUint32(byteOffset) & 0xff; | |
} | |
_Range? _rangeH(ByteData data, int width, Rect rect, int y, bool allowEmpty, 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); | |
} | |
_Range? _rangeV(ByteData data, int width, Rect rect, int x, bool allowEmpty, 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); | |
} | |
_Range? _range(List<int> alphas, bool allowEmpty, String position) { | |
if (alphas.any((alpha) => 0 < alpha && alpha < 255)) { | |
final suspects = alphas | |
.mapIndexed((index, alpha) => (index, alpha)) | |
.where((record) => 0 < record.$2 && record.$2 < 255) | |
.join(' '); | |
throw 'found neither fully transparent nor fully opaque pixels along $position, (offset, alpha):\n$suspects'; | |
} | |
final ranges = alphas.splitBetween((first, second) => first != second).toList(); | |
int start = 0; | |
List<_Range> rangeRecords = []; | |
for (final range in ranges) { | |
if (range[0] != 0) { | |
rangeRecords.add((start: start, length: range.length)); | |
} | |
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'; | |
} | |
// print('$alphas $rangeRecords'); | |
return rangeRecords.firstOrNull; | |
} | |
String _pos(String pos, int frame, Rect rect) => 'the $pos edge of frame #$frame ($rect) of $imageProvider'; | |
String _decorate(List<int> alphas) => alphas.map((a) => a == 0? '○' : '●').join(); | |
} | |
class _NinePatchPainter extends CustomPainter { | |
_NinePatchPainter({ | |
required this.image, | |
required this.centerSlice, | |
required this.colorFilter, | |
required this.opacity, | |
required this.fit, | |
required this.notifier, | |
this.debugLabel, | |
}); | |
final ui.Image image; | |
final Rect centerSlice; | |
final double opacity; | |
final ColorFilter? colorFilter; | |
final BoxFit? fit; | |
final ValueNotifier<Size> notifier; | |
final String? debugLabel; | |
@override | |
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; | |
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}'); | |
FlutterError.reportError( | |
FlutterErrorDetails( | |
exception: buffer.toString(), | |
) | |
); | |
} | |
paintImage( | |
canvas: canvas, | |
rect: Offset.zero & size, | |
image: image, | |
opacity: opacity, | |
colorFilter: colorFilter, | |
fit: fit, | |
centerSlice: widthFits && heightFits? centerSlice : null, | |
); | |
} | |
@override | |
bool shouldRepaint(_NinePatchPainter oldDelegate) => true; | |
} | |
mixin _NinePatchImageProviderStateMixin<T extends StatefulWidget> on State<T> { | |
ImageStream? _imageStream; | |
late final decoder = _NinePatchDecoder( | |
imageProvider: imageProvider, | |
); | |
@override | |
void didChangeDependencies() { | |
// debugPrintBeginFrameBanner = true; | |
super.didChangeDependencies(); | |
// We call getImage here because createLocalImageConfiguration() needs to | |
// be called again if the dependencies changed, in case the changes relate | |
// to the DefaultAssetBundle, MediaQuery, etc, which that method uses. | |
_getImage(); | |
} | |
void _getImage() { | |
// TODO short circuit: does it pay off? | |
// final imageRecord = _lruMap[widget.imageProvider]; | |
// if (imageRecord != null) { | |
// setState(() { | |
// // Trigger a build whenever the image changes. | |
// _imageRecord = imageRecord; | |
// }); | |
// return; | |
// } | |
final ImageStream? oldImageStream = _imageStream; | |
_imageStream = imageProvider.resolve(createLocalImageConfiguration(context)); | |
if (_imageStream!.key != oldImageStream?.key) { | |
// If the keys are the same, then we got the same image back, and so we don't | |
// need to update the listeners. If the key changed, though, we must make sure | |
// to switch our listeners to the new image stream. | |
final ImageStreamListener listener = ImageStreamListener(updateImage); | |
oldImageStream?.removeListener(listener); | |
_imageStream!.addListener(listener); | |
} | |
} | |
ImageProvider get imageProvider; | |
bool didProviderChange(T widget, T oldWidget); | |
void updateImage(ImageInfo imageInfo, bool synchronousCall); | |
@override | |
void didUpdateWidget(T oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (didProviderChange(widget, oldWidget)) { | |
_getImage(); | |
} | |
} | |
@override | |
void dispose() { | |
_imageStream?.removeListener(ImageStreamListener(updateImage)); | |
super.dispose(); | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:ui' as ui; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/scheduler.dart'; | |
import 'nine_patch.dart'; | |
main() { | |
runApp(MaterialApp( | |
scrollBehavior: const MaterialScrollBehavior().copyWith( | |
dragDevices: {ui.PointerDeviceKind.mouse, ui.PointerDeviceKind.touch}, | |
), | |
theme: ThemeData.light(useMaterial3: false), | |
home: _NinePatchExamples(), | |
)); | |
} | |
class _NinePatchExamples extends StatefulWidget { | |
@override | |
State<_NinePatchExamples> createState() => _NinePatchExamplesState(); | |
} | |
class _NinePatchExamplesState extends State<_NinePatchExamples> { | |
int cnt = 0; | |
bool down = false; | |
@override | |
Widget build(BuildContext context) { | |
// timeDilation = 5; | |
return Scaffold( | |
floatingActionButton: FloatingActionButton.extended( | |
onPressed: () => setState(() => cnt++), | |
label: const Text('animate'), | |
icon: const Icon(Icons.animation), | |
), | |
body: SingleChildScrollView( | |
child: Center( | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
Padding( | |
padding: const EdgeInsets.all(4), | |
child: Row( | |
children: [ | |
NinePatch( | |
debugLabel: 'android button', | |
imageProvider: const AssetImage('images/webp_btn_default_normal.9.webp'), | |
child: Material( | |
type: MaterialType.transparency, | |
child: InkWell( | |
onTap: () {}, | |
splashColor: Colors.white54, | |
child: ConstrainedBox( | |
constraints: const BoxConstraints(maxWidth: 100), | |
child: const Text('android button with maxWidth == 100'), | |
), | |
), | |
), | |
), | |
Expanded( | |
child: Builder( | |
builder: (context) { | |
// final text = ConstrainedBox( | |
// constraints: const BoxConstraints(minWidth: 11, minHeight: 34), | |
// child: const Text('Anim occaecat esse ullamco id aute veniam sunt incididunt elit mollit consequat.'), | |
// ); | |
final text = const Text('Anim occaecat esse ullamco id aute veniam sunt incididunt elit mollit consequat.'); | |
return Stack( | |
children: [ | |
Transform.translate( | |
offset: const Offset(2, 2), | |
child: ImageFiltered( | |
imageFilter: ui.ImageFilter.blur(sigmaX: 3, sigmaY: 3), | |
child: AnimatedNinePatch( | |
duration: const Duration(milliseconds: 500), | |
imageProvider: const AssetImage('images/webp_balloon.9.webp'), | |
color: Colors.black, | |
opacity: cnt.isEven? 0 : 1, | |
child: text, | |
), | |
), | |
), | |
AnimatedNinePatch( | |
duration: const Duration(milliseconds: 500), | |
imageProvider: const AssetImage('images/webp_balloon.9.webp'), | |
color: cnt.isEven? Colors.green.shade300 : Colors.green.shade100, | |
blendMode: BlendMode.modulate, | |
child: text, | |
), | |
], | |
); | |
} | |
), | |
), | |
], | |
), | |
), | |
AnimatedMultiNinePatch( | |
imageProvider: AssetImage('images/webp_balloon_multi_frame.9.webp'), | |
// 'images/webp_balloon_multi_frame.9.webp' contains 10 fixed | |
// size frames (with size Size(69, 49)) aligned horizontally | |
// so the whole image size is Size(10 * 69, 49) | |
frameBuilder: horizontalFixedSizeFrameBuilder(10, const Size(69, 49)), | |
color: down? Colors.yellow.shade600 : Colors.teal.shade400, | |
blendMode: BlendMode.modulate, | |
phase: down? 0 : 1, | |
// index: down? 0 : 9, | |
duration: const Duration(milliseconds: 300), | |
// debugLabel: 'multi', | |
child: ConstrainedBox( | |
constraints: const BoxConstraints(maxWidth: 200), | |
child: GestureDetector( | |
onTapDown: (d) => setState(() => down = true), | |
onTapUp: (d) => setState(() => down = false), | |
onTapCancel: () => setState(() => down = false), | |
child: const Text('press me and hold your finger for a while...'), | |
), | |
), | |
), | |
Padding( | |
padding: const EdgeInsets.all(4), | |
child: NinePatch( | |
colorFilter: ColorFilter.mode(cnt.isOdd? Colors.deepOrange : Colors.orange, BlendMode.modulate), | |
imageProvider: const AssetImage('images/webp_flag.9.webp'), | |
child: ConstrainedBox( | |
constraints: const BoxConstraints(maxWidth: 250), | |
child: OutlinedButton( | |
style: const ButtonStyle( | |
foregroundColor: MaterialStatePropertyAll(Colors.black), | |
), | |
onPressed: () { | |
invalidateNinePatchCacheItem(const AssetImage('images/webp_flag.9.webp')); | |
}, | |
child: const Text('NinePatch with "centerSlice" and "padding" embedded in image'), | |
), | |
), | |
), | |
), | |
Padding( | |
padding: const EdgeInsets.all(4), | |
child: SingleChildScrollView( | |
scrollDirection: Axis.horizontal, | |
child: Row( | |
children: [ | |
AnimatedNinePatch( | |
duration: const Duration(milliseconds: 500), | |
color: cnt.isOdd? Colors.yellow.shade600 : Colors.red.shade600, | |
blendMode: BlendMode.modulate, | |
imageProvider: cnt.isOdd? const AssetImage('images/webp_flag.9.webp') : const AssetImage('images/webp_flag1.9.webp'), | |
child: ConstrainedBox( | |
constraints: const BoxConstraints(maxWidth: 100), | |
child: const Text('this is AnimetedNinePatch widget'), | |
), | |
), | |
ConstrainedBox( | |
constraints: const BoxConstraints(maxWidth: 200), | |
child: AnimatedNinePatch( | |
duration: const Duration(milliseconds: 500), | |
color: cnt.isOdd? Colors.red.shade600 : Colors.yellow.shade600, | |
blendMode: BlendMode.modulate, | |
imageProvider: cnt.isOdd? const AssetImage('images/webp_flag1.9.webp') : const AssetImage('images/webp_flag.9.webp'), | |
child: const Text('this is AnimetedNinePatch widget'), | |
), | |
), | |
], | |
), | |
), | |
), | |
Stack( | |
alignment: Alignment.center, | |
children: [ | |
AnimatedNinePatch( | |
duration: const Duration(milliseconds: 1000), | |
imageProvider: const AssetImage('images/webp_balloon.9.webp'), | |
color: cnt.isEven? null : Colors.green.shade100, | |
blendMode: BlendMode.modulate, | |
opacity: cnt.isEven? 0.33 : 1, | |
curve: Curves.easeOut, | |
child: AnimatedSize( | |
curve: Curves.easeOutCubic, | |
duration: const Duration(milliseconds: 600), | |
child: ConstrainedBox( | |
// constraints: const BoxConstraints(minWidth: 11, maxWidth: 150, minHeight: 34), | |
constraints: const BoxConstraints(maxWidth: 225), | |
child: cnt.isEven? const UnconstrainedBox() : const Text('Est Lorem non eu cupidatat. Sint occaecat excepteur minim nisi sint elit culpa eu officia dolor ut.') | |
), | |
), | |
), | |
NinePatch( | |
imageProvider: const AssetImage('images/webp_balloon_empty.9.webp'), | |
opacity: cnt.isEven? 0.33 : 1, | |
child: AnimatedSize( | |
curve: Curves.easeIn, | |
duration: const Duration(milliseconds: 400), | |
child: ConstrainedBox( | |
// constraints: const BoxConstraints(minWidth: 11, maxWidth: 150, minHeight: 34), | |
constraints: const BoxConstraints(maxWidth: 225), | |
child: cnt.isEven? const UnconstrainedBox() : const Opacity(opacity: 0, child: Text('Est Lorem non eu cupidatat. Sint occaecat excepteur minim nisi sint elit culpa eu officia dolor ut.')) | |
), | |
), | |
), | |
], | |
), | |
AnimatedNinePatch( | |
duration: const Duration(milliseconds: 1000), | |
imageProvider: const AssetImage('images/webp_balloon.9.webp'), | |
color: cnt.isEven? null : Colors.green.shade200, | |
blendMode: BlendMode.modulate, | |
opacity: cnt.isEven? 0.33 : 1, | |
curve: Curves.easeOut, | |
debugLabel: 'balloon', | |
child: AnimatedSize( | |
duration: const Duration(milliseconds: 500), | |
child: ConstrainedBox( | |
// constraints: const BoxConstraints(minWidth: 11, maxWidth: 150, minHeight: 34), | |
constraints: const BoxConstraints(maxWidth: 225), | |
child: cnt.isEven? const UnconstrainedBox() : const Text('Est Lorem non eu cupidatat. Sint occaecat excepteur minim nisi sint elit culpa eu officia dolor ut.') | |
), | |
), | |
), | |
Padding( | |
padding: const EdgeInsets.all(4), | |
child: Builder( | |
builder: (context) { | |
final child = ConstrainedBox( | |
constraints: const BoxConstraints(maxWidth: 200), | |
child: const Text('Sit occaecat tempor incididunt pariatur id amet aliqua magna mollit irure consequat commodo.'), | |
); | |
return Stack( | |
children: [ | |
ImageFiltered( | |
imageFilter: ui.ImageFilter.blur(sigmaX: 3, sigmaY: 3), | |
child: Transform.translate( | |
offset: const Offset(2, 2), | |
child: NinePatch( | |
colorFilter: const ColorFilter.mode(Colors.black, BlendMode.srcATop), | |
imageProvider: const AssetImage('images/webp_flag.9.webp'), | |
child: child, | |
), | |
), | |
), | |
NinePatch( | |
debugLabel: 'yellow flag', | |
colorFilter: ColorFilter.mode(Colors.yellow.shade300, BlendMode.modulate), | |
imageProvider: const AssetImage('images/webp_flag.9.webp'), | |
child: child, | |
), | |
], | |
); | |
} | |
), | |
), | |
Padding( | |
padding: const EdgeInsets.all(4), | |
child: SizedBox( | |
height: 150, | |
child: PageView.builder( | |
itemCount: 10, | |
itemBuilder: (ctx, i) => Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 50), | |
child: NinePatch( | |
colorFilter: ColorFilter.mode(i.isOdd? Colors.blue : Colors.blueGrey, BlendMode.modulate), | |
imageProvider: const AssetImage('images/webp_flag.9.webp'), | |
child: FittedBox(child: Text('item $i')), | |
), | |
), | |
), | |
), | |
), | |
], | |
), | |
), | |
), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment