📱

【Flutter】ListTile風の挙動をする高さ設定可能なWidgetの作成

2021/08/07に公開

動作確認環境

Flutter 2.2.3 • channel stable • https://github.com/flutter/flutter.git
Framework • revision f4abaa0735 (5 weeks ago) • 2021-07-01 12:46:11 -0700
Engine • revision 241c87ad80
Tools • Dart 2.13.4

ListTileの特性

ListTile自体はそのまま使えればListViewやUITableViewのような表示が簡単にでき、便利ではあるものの、

  • titleのみなら高さ56.0
  • titlesubtitleがあるなら高さ72.0
    と固定の高さで描画され、titlesubtitleの行数に応じて固定で高さが増えるつくりになっている。

ListTile自体にheightを設定する項目もなく、ContainerExpandedListTileを囲んで高さを広がるように調整しようとすれば、Ripple Effect(タップ時の波紋)が半端だったり、意図しない隙間ができたりと、見栄え悪い形になる。

FlutterのListTile APIにも以下のように記載があり、このWidget自体が固定の高さで作られるものとして記載されている。

A single fixed-height row that typically contains some text as well as a leading or trailing icon.
(高さが固定された1つの行で、通常、いくつかのテキストと先頭または末尾のアイコンが含まれています。)

https://api.flutter.dev/flutter/material/ListTile-class.html

なので、ListTileを使って高さを任意の値にしようとすることは諦め、自前で独自のWidgetを作ることになる。

ListTileの代替Widget

titlesubtitleTextStyleを除いて、ListTileと近いものとして以下のような作りで実現(複雑になりすぎたので、もう少しシンプルに改良できるところはありそう)。
TextStyleは、VariableHeightListTileに渡すText内で記載してあげれば反映されるため作るのが面倒臭いので省いた

VariableHeightListTile
class VariableHeightListTile extends StatelessWidget {
  const VariableHeightListTile({
    Key? key,
    this.leading,
    required this.title,
    this.subtitle,
    this.trailing,
    this.onTap,
    this.height,
  }) : super(key: key);

  final Widget? leading;
  final Widget title;
  final Widget? subtitle;
  final Widget? trailing;
  final VoidCallback? onTap;
  final double? height;

  
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    final _leading = leading ?? SizedBox.shrink();
    final _trailing = trailing ?? SizedBox.shrink();
    final double _padding = 16.0;
    final _height = (height ?? 56.0) - _padding * 2;
    final MainAxisAlignment _mainAxisAlignment =
        (_leading is SizedBox || _trailing is SizedBox)
            ? MainAxisAlignment.spaceEvenly
            : MainAxisAlignment.start;

    SizedBox _margin(Widget widget) {
      return (widget is SizedBox) ? SizedBox.shrink() : SizedBox(width: 32.0);
    }

    Widget _texts({
      required Widget title,
      Widget? subtitle,
    }) {
      final _title = title;
      final _subtitle = subtitle ?? SizedBox.shrink();
      final _defaultTitleStyle = theme.textTheme.bodyText1!;
      final _defaultSubtitleStyle = theme.textTheme.subtitle1!;

      return LayoutBuilder(builder: (context, size) {
        bool _isMultiLine = false;

        if (_title is Text) {
          final style = _title.style ?? _defaultTitleStyle;
          final span = TextSpan(text: _title.data, style: style);
          final tp = TextPainter(
            text: span,
            maxLines: 1,
            textDirection: TextDirection.ltr,
          );
          tp.layout(maxWidth: size.maxWidth);
          _isMultiLine |= tp.didExceedMaxLines;
        }
        if (_subtitle is Text) {
          final style = _subtitle.style ?? _defaultSubtitleStyle;
          final span = TextSpan(text: _subtitle.data, style: style);
          final tp = TextPainter(
            text: span,
            maxLines: 1,
            textDirection: TextDirection.ltr,
          );
          tp.layout(maxWidth: size.maxWidth);
          _isMultiLine |= tp.didExceedMaxLines;
        }
        double _padding = (_isMultiLine) ? 5.0 : 16.0;
        CrossAxisAlignment _crossAxisAlignment = (_title is Text)
            ? CrossAxisAlignment.start
            : CrossAxisAlignment.center;

        return Padding(
          padding: EdgeInsets.symmetric(vertical: _padding),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: _crossAxisAlignment,
            children: [
              _title,
              _subtitle,
            ],
          ),
        );
      });
    }

    return InkWell(
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: _padding),
        child: ConstrainedBox(
          constraints: BoxConstraints(
            minHeight: _height,
          ),
          child: Row(
            mainAxisAlignment: _mainAxisAlignment,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              _leading,
              _margin(_leading),
              Expanded(
                child: _texts(
                  title: title,
                  subtitle: subtitle,
                ),
              ),
              _margin(_trailing),
              _trailing,
            ],
          ),
        ),
      ),
      onTap: onTap,
    );
  }
}

ListTileのシンプルな代替Widget

VariableHeightListTileは柔軟な作りにしようとした結果、複雑な内部構造になってしまった(よりListTileを真似るならListTileのコードをコピーして、高さを指定できるように改変した方が楽)。
ただ実際に使う時は、1つのListView内で`数パターンの形しか使わないため、もっとシンプルな形で代替Widgetを用意できる。以下に例として記載する。
CodePen貼ろうとしたら、CodePenはnull-safetyに非対応で使えなかったため、DartPadで実行サンプルを貼っておく。

title または titleとsubtitle

Sample

class ImitationListTile extends StatelessWidget {
  const ImitationListTile({
    Key? key,
    required this.title,
    this.subtitle,
    this.height,
    this.onTap,
  }) : super(key: key);

  final Text title;
  final Text? subtitle;
  final double? height;
  final VoidCallback? onTap;

  
  Widget build(BuildContext context) {
    final _subtitle = subtitle ?? const SizedBox.shrink();
    final _height = height ?? 54.0;

    return InkWell(
      child: ConstrainedBox(
        constraints: BoxConstraints(minHeight: _height),
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              title,
              _subtitle,
            ],
          ),
        ),
      ),
      onTap: onTap,
    );
  }
}

leadingとtitle

Sample

class ImitationListTile extends StatelessWidget {
  const ImitationListTile({
    Key? key,
    required this.leading,
    required this.title,
    this.height,
    this.onTap,
  }) : super(key: key);

  final Icon leading;
  final Text title;
  final double? height;
  final VoidCallback? onTap;

  
  Widget build(BuildContext context) {
    final _height = height ?? 54.0;

    return InkWell(
      child: ConstrainedBox(
        constraints: BoxConstraints(minHeight: _height),
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Row(
            children: [
              leading,
              const SizedBox(width: 32.0),
              title,
            ],
          ),
        ),
      ),
      onTap: onTap,
    );
  }
}

titleとtrailing または titleとsubtitleとtrailing

Sample

class ImitationListTile extends StatelessWidget {
  const ImitationListTile(
      {required this.title, required this.trailing, this.subtitle, this.height, this.onTap});

  final Text title;
  final Text? subtitle;
  final Widget trailing;
  final double? height;
  final VoidCallback? onTap;

  
  Widget build(BuildContext context) {
    final _subtitle = subtitle ?? const SizedBox.shrink();
    final _height = height ?? 54.0;

    return InkWell(
      child: ConstrainedBox(
        constraints: BoxConstraints(minHeight: _height),
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Row(
            children: [
              Expanded(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    title,
                    _subtitle,
                  ],
                ),
              ),
              const SizedBox(width: 16.0),
              trailing,
            ],
          ),
        ),
      ),
      onTap: onTap,
    );
  }
}

Discussion