Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Flutter: RenderObjectWidgetを使いはみ出た分が切り取られるウィジェットを作る

今回はこういう表示が出来るようになるウィジェットを作ってみたいと思います。

(Floating Action Buttonが半分切れてるが工事中マークが出ていない事に注目、さらに言えばDEBUGの帯もちょっと欠けてる)

はじめに

Flutterのウィジェットとそのレイアウトシステムは基本的にウィンドウ(画面)サイズに合わせてその中身をレイアウトするようになっており、ウィンドウサイズが小さくなれば中身もその分小さくレイアウトしようとします。

例えばこのようなアプリ

のウィンドウサイズを小さくすれば中身もそれに応じてレイアウトされていきますが、

Flutterアプリは中身がウィンドウサイズより大きくならざるを得ない状況になった場合にブラウザでのHTML表示のように自動的に縦または横にスクロールできるようになったりはしないので、限界を越えるとレイアウト計算が破綻し、アプリをデバッグ実行している場合はFlutter開発者おなじみ?の工事中マークが表示されたりします。

もちろん、アプリの作りを工夫することでどれだけ小さくなっても破綻しないようにも出来ると思いますが、常に実用的でない小ささまで考慮しながらUIを組むのは労多くして得られるものが少なく現実的ではありません。

そこで、もうある程度以下のサイズは諦めてしまおうということで「中身は最低でもこのサイズでレイアウトしつつはみ出る分は切り取られる」という動作が出来るウィジェットを、今回はSingleChildRenderObjectWidgetRenderProxyBoxを使って作ってみようと思います。

環境

執筆時点のstable、v2.8.0を使います。

[✓] Flutter (Channel stable, 2.8.0, on macOS 12.0.1 21A559 darwin-x64, locale ja-JP)

コード全文と概要

今回は OverflowClipping という名前のウィジェットを作る事にします。

引数はウイジェットのkeyの他に

  • required の child
  • 既定値を持ちつつ設定可能な最小の幅/高さの minWidthminHeight

を持つこととし、minWidth / minHeight 以上の領域が使える場合は普通にchildをレイアウト、ウィジェット自身には最小サイズ以下の領域しかない場合でもchildは最小サイズでレイアウトしつつ描画がはみ出る分は切り取られるようにします。

まず以下にコード全文を掲載します(「詳細」をクリックすると開きます)


import 'dart:math';

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

@immutable
class OverflowClipping extends SingleChildRenderObjectWidget {
  const OverflowClipping({
    required Widget child,
    this.minWidth = 240,
    this.minHeight = 240,
    Key? key,
  }) : super(key: key, child: child);

  final double minWidth;
  final double minHeight;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return _RenderOverflowClipping(
      minWidth: minWidth,
      minHeight: minHeight,
    );
  }

  @override
  void updateRenderObject(
    BuildContext context,
    _RenderOverflowClipping renderObject,
  ) {
    renderObject
      ..minWidth = minWidth
      ..minHeight = minHeight;
  }
}

class _RenderOverflowClipping extends RenderProxyBox {
  _RenderOverflowClipping({
    required double minWidth,
    required double minHeight,
  })  : _minWidth = minWidth,
        _minHeight = minHeight;

  double _minWidth;
  double get minWidth => _minWidth;
  set minWidth(double newValue) {
    if (_minWidth == newValue) {
      return;
    }
    _minWidth = newValue;
    markNeedsLayout();
  }

  double _minHeight;
  double get minHeight => _minHeight;
  set minHeight(double newValue) {
    if (_minHeight == newValue) {
      return;
    }
    _minHeight = newValue;
    markNeedsLayout();
  }

  @override
  void performLayout() {
    final child = this.child;
    if (child == null) {
      return super.performLayout();
    }

    size = constraints.biggest;

    child.layout(
      BoxConstraints.tightFor(
        width: max(minWidth, constraints.maxWidth),
        height: max(minHeight, constraints.maxHeight),
      ),
    );
  }

  final _clipRectLayer = LayerHandle<ClipRectLayer>();

  @override
  void paint(PaintingContext context, Offset offset) {
    final child = this.child;
    if (child != null) {
      _clipRectLayer.layer = context.pushClipRect(
        needsCompositing,
        offset,
        Offset.zero & size,
        super.paint,
        oldLayer: _clipRectLayer.layer,
      );
    }
  }
}

解説

以下、おおまかな実装内容を順に解説していきます。

@immutable
class OverflowClipping extends SingleChildRenderObjectWidget {
  const OverflowClipping({
    required Widget child,
    this.minWidth = 240,
    this.minHeight = 240,
    Key? key,
  }) : super(key: key, child: child);

  final double minWidth;
  final double minHeight;

child を1つもちRenderObjectレベルの実装が出来るウィジェットを作る、ということで SingleChildRenderObjectWidget を継承します。

minWidthminHeight の既定値は適当にほどほどの値を設定しました。

  @override
  RenderObject createRenderObject(BuildContext context) {
    return _RenderOverflowClipping(
      minWidth: minWidth,
      minHeight: minHeight,
    );
  }

  @override
  void updateRenderObject(
    BuildContext context,
    _RenderOverflowClipping renderObject,
  ) {
    renderObject
      ..minWidth = minWidth
      ..minHeight = minHeight;
  }
}

_RenderOverflowClipping というRenderObject(この後解説します)を生成したり、RenderObject更新時にそのプロパティを更新する実装をしています。

class _RenderOverflowClipping extends RenderProxyBox {
  _RenderOverflowClipping({
    required double minWidth,
    required double minHeight,
  })  : _minWidth = minWidth,
        _minHeight = minHeight;

  double _minWidth;
  double get minWidth => _minWidth;
  set minWidth(double newValue) {
    if (_minWidth == newValue) {
      return;
    }
    _minWidth = newValue;
    markNeedsLayout();
  }

  double _minHeight;
  double get minHeight => _minHeight;
  set minHeight(double newValue) {
    if (_minHeight == newValue) {
      return;
    }
    _minHeight = newValue;
    markNeedsLayout();
  }

渡された child をそのままレンダリングするRenderObjectである RenderProxyBox を継承し、レンダリング部分に手を入れたものを作ります。

引数として minWidth minHeight を持ち、OverflowClipping に設定された値を受け取る事とします。

それぞれについて値を保持している「 _ 」付きのprivateな値 (_minWidth _minHeight)とgetter、そして以前と違う値の場合に再レイアウトが必要とマーキングする処理を呼び出しつつ保持している値を更新するsetterを実装しています。

  @override
  void performLayout() {
    final child = this.child;
    if (child == null) {
      return super.performLayout();
    }

    size = constraints.biggest;

    child.layout(
      BoxConstraints.tightFor(
        width: max(minWidth, constraints.maxWidth),
        height: max(minHeight, constraints.maxHeight),
      ),
    );
  }

処理のキモその1です。

まず child がnullの時は特に何もしない(親クラスに処理をまかせる)、とします。

child が存在する場合は、まず自身のサイズ( size )は今自身に与えられているレイアウト制約( constraints )で許される最大の大きさとします。

そして child をレイアウトする際、その際のレイアウト制約は幅/高さそれぞれ少なくとも最小値以上とし、またlayout()parentUsesSize は省略(=デフォルトのfalse)、すなわち「子のレイアウト情報を親は使わない」としています。

こうすることで、自身のサイズに関係なく中身は最小サイズ以下にならないようにします。

  final _clipRectLayer = LayerHandle<ClipRectLayer>();

  @override
  void paint(PaintingContext context, Offset offset) {
    final child = this.child;
    if (child != null) {
      _clipRectLayer.layer = context.pushClipRect(
        needsCompositing,
        offset,
        Offset.zero & size,
        super.paint,
        oldLayer: _clipRectLayer.layer,
      );
    }
  }

処理のキモその2です。

「その1」で中身は最小サイズ以下にならないようにしましたが、そのままだと中身の描画がはみ出る事になります。

  • さすがにアプリのウィンドウの外へ描画がはみ出す事はありませんが、アプリ内の一部分で同様の事を行った場合は特にエラーになったりもせずガンガン中身がはみ出して描画されます

そこで、child が描画されようとするタイミングで、context.pushClipRect() を用いて child の描画のはみ出した部分を切り取ります。
(似たような実装は例えばClipRectウィジェットの中身などで行われます)

パフォーマンス向上のため、前回の pushClipRect() の実行結果をキャッシュする _clipRectLayer を用意し oldLayer に設定しています。

おわりに

以上で OverflowClipping ウィジェットが実装できました。

これで、例えば

void main() {
  runApp(
    const OverflowClipping(
      child: MyApp(),
    ),
  );
}

のようにアプリ全体をwrapしてしまえば、このアプリは最小でも縦横240以上でレイアウトされ、ウィンドウサイズがそれ以下になっても...仮に

ほとんど大きさが無くなってしまっても

レイアウトのエラーは発生しなくなります。またアプリ全体に限らず「場合によっては切れたり消えたりしても構わない」というものがアプリの中で部分的に使えると便利かもしれません。

RenderObjectWidgetを使った実装は普段から頻繁に行うことは少ないと思いますが、直接的な描画処理に比較的近い部分をいじる事で通常では行いにくい事も出来たりと、より実装の可能性が広がるかなと思います。



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。