0

I'm trying to create a custom route transition using Flutter. The existing route transitions (fade, scale, etc) are fine, but I want to create screen transitions that manipulate the screens' content by capturing their render and applying effects to it. Basically, I want to recreate DOOM screen melt effect as a route transition with Flutter.

DOOM Screen melt example

It feels to me that its reliance on Skia and its own Canvas for rendering screen elements would make this possible, if not somewhat trivial. I haven't been able to do it, though. I can't seem to be able to capture the screen, or at least to render the target screen in chunks using clipping paths. A lot of this has to do with my lack of understanding of how Flutter composition works, so I'm still uncertain on which avenues to investigate.

My first approach was creating a custom transition by basically replicating what FadeTransition does.

Route createRouteWithTransitionCustom() {
  return PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => ThirdScreen(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return CustomTransition(
        animation: animation,
        child: child,
      );
    },
  );
}

RaisedButton(
  child: Text('Open Third screen (custom transition, custom code)'),
  onPressed: () {
    Navigator.push(context, createRouteWithTransitionCustom());
  },
),

In this case, CustomTransition is a near exact duplicate of FadeTransition, with some light renaming (opacity becomes animation).

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

import 'RenderAnimatedCustom.dart';

/// A custom transition to animate a widget.
/// This is a copy of FadeTransition: https://github.com/flutter/flutter/blob/27321ebbad/packages/flutter/lib/src/widgets/transitions.dart#L530
class CustomTransition extends SingleChildRenderObjectWidget {
  const CustomTransition({
    Key key,
    @required this.animation,
    this.alwaysIncludeSemantics = false,
    Widget child,
  }) : assert(animation != null),
       super(key: key, child: child);

  final Animation<double> animation;

  final bool alwaysIncludeSemantics;

  @override
  RenderAnimatedCustom createRenderObject(BuildContext context) {
    return RenderAnimatedCustom(
      buildContext: context,
      phase: animation,
      alwaysIncludeSemantics: alwaysIncludeSemantics,
    );
  }

  @override
  void updateRenderObject(BuildContext context, RenderAnimatedCustom renderObject) {
    renderObject
      ..phase = animation
      ..alwaysIncludeSemantics = alwaysIncludeSemantics;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<Animation<double>>('animation', animation));
    properties.add(FlagProperty('alwaysIncludeSemantics', value: alwaysIncludeSemantics, ifTrue: 'alwaysIncludeSemantics'));
  }
}

This new CustomTransition also creates a new RenderAnimatedCustom inside createRenderObject() (instead of FadeTransition's own RenderAnimatedOpacity). Sure enough, my custom RenderAnimatedCustom is a near duplicate of RenderAnimatedOpacity:

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

/// A custom renderer.
/// This is a copy of RenderAnimatedOpacity: https://github.com/flutter/flutter/blob/27321ebbad/packages/flutter/lib/src/rendering/proxy_box.dart#L825
class RenderAnimatedCustom extends RenderProxyBox {
  RenderAnimatedCustom({
    @required BuildContext buildContext,
    @required Animation<double> phase,
    bool alwaysIncludeSemantics = false,
    RenderBox child,
  }) : assert(phase != null),
       assert(alwaysIncludeSemantics != null),
       _alwaysIncludeSemantics = alwaysIncludeSemantics,
       super(child) {
    this.phase = phase;
    this.buildContext = buildContext;
  }

  BuildContext buildContext;
  double _lastUsedPhase;

  @override
  bool get alwaysNeedsCompositing => child != null && _currentlyNeedsCompositing;
  bool _currentlyNeedsCompositing;

  Animation<double> get phase => _phase;
  Animation<double> _phase;
  set phase(Animation<double> value) {
    assert(value != null);
    if (_phase == value) return;
    if (attached && _phase != null) _phase.removeListener(_updatePhase);
    _phase = value;
    if (attached) _phase.addListener(_updatePhase);
    _updatePhase();
  }

  /// Whether child semantics are included regardless of the opacity.
  ///
  /// If false, semantics are excluded when [opacity] is 0.0.
  ///
  /// Defaults to false.
  bool get alwaysIncludeSemantics => _alwaysIncludeSemantics;
  bool _alwaysIncludeSemantics;
  set alwaysIncludeSemantics(bool value) {
    if (value == _alwaysIncludeSemantics) return;
    _alwaysIncludeSemantics = value;
    markNeedsSemanticsUpdate();
  }

  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
    _phase.addListener(_updatePhase);
    _updatePhase(); // in case it changed while we weren't listening
  }

  @override
  void detach() {
    _phase.removeListener(_updatePhase);
    super.detach();
  }

  void _updatePhase() {
    final double newPhase = _phase.value;
    if (_lastUsedPhase != newPhase) {
      _lastUsedPhase = newPhase;
      final bool didNeedCompositing = _currentlyNeedsCompositing;
      _currentlyNeedsCompositing = _lastUsedPhase > 0 && _lastUsedPhase < 1;
      if (child != null && didNeedCompositing != _currentlyNeedsCompositing) {
        markNeedsCompositingBitsUpdate();
      }
      markNeedsPaint();
      if (newPhase == 0 || _lastUsedPhase == 0) {
        markNeedsSemanticsUpdate();
      }
    }
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      if (_lastUsedPhase == 0) {
        // No need to keep the layer. We'll create a new one if necessary.
        layer = null;
        return;
      }
      if (_lastUsedPhase == 1) {
        // No need to keep the layer. We'll create a new one if necessary.
        layer = null;
        context.paintChild(child, offset);
        return;
      }
      assert(needsCompositing);

      // Basic example, slides the screen in
      context.paintChild(child, Offset((1 - _lastUsedPhase) * 255, 0));
    }
  }

  @override
  void visitChildrenForSemantics(RenderObjectVisitor visitor) {
    if (child != null && (_lastUsedPhase != 0 || alwaysIncludeSemantics)) {
      visitor(child);
    }
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<Animation<double>>('phase', phase));
    properties.add(FlagProperty('alwaysIncludeSemantics', value: alwaysIncludeSemantics, ifTrue: 'alwaysIncludeSemantics'));
  }
}

Finally, the problem is as such. In the above file, inside paint(), this placeholder code simply moves the screen sideways by rendering with different offsets using context.paintChild().

But I want to draw chunks of child instead. In this case, vertical strips so I can create the screen melt effect. But that's really just an example; I want to find a way to manipulate the render of the child so I could have other different image-based effects.

What I have tried

Looping and drawing parts with clip rectangles

Instead of simply doing context.paintChild(child, offset), I've tried looping and drawing it chunk by chunk. This is not super generic, but would work for the screen melt effect at least.

Inside paint() (ignore the clumsiness of the prototype code):

int segments = 10;
double width = context.estimatedBounds.width;
double height = context.estimatedBounds.height;
for (var i = 0; i < segments; i++) {
  double l = ((width / segments) * i).round().toDouble();
  double r = ((width / segments) * (i + 1)).round().toDouble();
  double y = (1 - _lastUsedPhase) * 50 * (i + 1);
  layer = context.pushClipRect(
    true,
    Offset(l, y),
    Rect.fromLTWH(0, 0, r - l, height),
    (c, o) => c.paintChild(child, Offset(0, o.dy)),
    oldLayer: layer
  );
}

Unfortunately, this doesn't work. Every call to paintChild() seems to clear out the previous call, so only the last "strip" is kept.

Transition example

I've tried combinations of this with different "layer" properties, using clipRectAndPaint(), etc, but can't get anything different from the above example.

Capturing the image with toImage()

I haven't gone much further in this, but my first attempt was of course to simply capture a widget as an image, something I assume was straightforward.

Unfortunately this requires that my widget is wrapped around a RepaintBoundary() in the custom route. Something like this:

return CustomTransition(
  animation: animation,
  child: RepaintBoundary(child: child),
);

Then maybe we could just do child.toImage(), manipulate that inside a canvas, and present that.

My issue with that is that every time one defines the transition, the child would need to be wrapped in that way. I'd like the CustomTransition() to handle that instead, but I haven't found a way and I'm wondering if this is actually necessary.

There are other classes with a toImage() function - Picture, Scene, OffsetLayer - but none of them seemed to be readily available. Ideally there would be an easy way for me to capture stuff as an image from inside paint() on RenderAnimatedCustom`. I could then do any kind of manipulation to that image, and paint it instead.

Other orthogonal solutions

I know there's several answers on StackOverflow (and other places) about how to "capture an image from a Widget", but they seem to specific to using an existing Canvas, using RepaintBoundary, etc.

In sum, what I need is: a way to create custom canvas-manipulating screen transitions. An ability to capture arbitrary widgets (without an explicit RepaintBoundary) seem to be the key to this.

Any hints? Am I being foolish for avoiding RepaintBoundary? Is it the only way? Or is there any other way for me to use "layers" for accomplish this sort of segmented child drawing?

Minimal source code for this app example is available on GitHub.

PS. I'm aware the transition example as it is is trying to manipulate the oncoming screen, not the outgoing one, as it should work for this to work like Doom's screen melt. That's a further problem I'm not investigating right now.

zeh
  • 10,130
  • 3
  • 38
  • 56
  • 2
    seems you need to go low-level: you need a custom `SingleChildRenderObjectWidget` that creates a custom `RenderProxyBox` which overrides `isRepaintBoundary` to true and `paint(PaintingContext context, Offset offset)` where you will do your custom painting – pskink Jan 24 '20 at 20:41
  • 1
    and i forgot to say why `isRepaintBoundary` should be set to `true` - the docs say: *"If this getter returns true, the paintBounds are applied to this object and all descendants. The framework automatically creates an OffsetLayer and assigns it to the layer field."* - so you can use `OffsetLayer.toImage()` method to grab the image – pskink Jan 25 '20 at 05:03
  • Thanks @pskink. I'll investigate that more. I'm already extending with `SingleChildRenderObjectWidget` with `CustomTransition `, and `RenderAnimatedCustom` is my custom `RenderProxyBox`, but I'm not overriding `isRepaintBoundary` yet. I _am_ using a custom `paint()`, but it's not clear how I can _capture_ the current `child`'s render there so I can then do my custom painting... interesting to know `isRepaintBoundary` actually changes the internal behavior rather than being just an informative, high-level getter. I just need to find the `OffsetLayer` reference from there I suppose. – zeh Jan 25 '20 at 18:07
  • 1
    actually your post got me interest and since i had some spare time today i wrote this: https://pastebin.com/ampJAMeS - custom `SingleChildRenderObjectWidget` is minimalistic here and needs i think `updateRenderObject` and other stuff but it works – pskink Jan 25 '20 at 18:13
  • Nice! This does seem to work. The performance overlay implies that it's still able to do it all at 60fps, although I'm running the iOS simulator. I'll digest this and try coming up with a version that works with my main codebase and post it as an answer with credits to you (unless you want to write one first for "points"). – zeh Jan 27 '20 at 13:41
  • 1
    sure, feel free to post a self answer, did you try to run it on some real device? i just tested on desktop (`debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia;`) and could not test on any real device – pskink Jan 27 '20 at 13:49
  • Yep - working super well on both Android and iOS. I had to do some adjustments to how the image is captured, being a bit more aggressive about it and checking during paint() because "layer" is not known when animation changes status. Render life cycle is a bit different on mobile? Anyway, it works, I just need to clean up the messy code. Here's a preview: https://www.dropbox.com/s/nembzmfmlgfcdic/flutter-screen-melt.gif?dl=0 – zeh Jan 27 '20 at 15:20

0 Answers0