Flutterで日本語の縦書きを実現する②

Flutterで日本語の縦書きを実現する方法 第2弾です。
前回の記事（2021年12月20日）では、TextPainter を使用して縦書き文章を描画する方法を解説しましたが、「改行に対応してない」「実装コードが複雑」といった欠点がありました。

↓ 前回の記事はこちら

https://qiita.com/sakusaku3939/items/64d453f4cf4be875aa67

そこで、今回は改良版である Wrap ウィジェットを使用した描画方法について解説したいと思います。

|今回の記事（Wrapを使用する方法）|前回の記事|
|:---:|:---:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/985195/6fe086f6-42c6-578d-fb6a-3e3cda428fb2.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/985195/483ca4c9-b364-ee15-a37b-36225efdb675.png)|


# 完成版

```dart:main.dart
import 'package:flutter/material.dart';
import 'package:untitled/tategaki.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: _MyHomePage(),
    );
  }
}

class _MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter Demo Home Page'),
      ),
      body: Container(
        margin: const EdgeInsets.all(12),
        child: Tategaki(
          "吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。"
          "何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。\n"
          "吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。"
          "この書生というのは時々我々を捕えて煮て食うという話である。しかしその当時は何という考もなかったから別段恐しいとも思わなかった。"
          "ただ彼の掌に載せられてスーと持ち上げられた時何だかフワフワした感じがあったばかりである。",
          style: const TextStyle(fontSize: 24, height: 1.0),
        ),
      ),
    );
  }
}
```

```dart:tategaki.dart
import 'package:flutter/material.dart';
import 'package:untitled/vertical_rotated.dart';

class Tategaki extends StatelessWidget {
  Tategaki(
    this.text, {
    this.style,
    this.space = 12,
  });

  final String text;
  final TextStyle? style;
  final double space;

  @override
  Widget build(BuildContext context) {
    final splitText = text.split("\n");
    return Row(
      textDirection: TextDirection.rtl,
      children: [
        for (var s in splitText) _textBox(s.runes),
      ],
    );
  }

  Widget _textBox(Runes runes) {
    return Wrap(
      textDirection: TextDirection.rtl,
      direction: Axis.vertical,
      children: [
        for (var rune in runes)
          Row(
            children: [
              SizedBox(width: space),
              _character(String.fromCharCode(rune)),
            ],
          )
      ],
    );
  }

  Widget _character(String char) {
    if (VerticalRotated.map[char] != null) {
      return Text(VerticalRotated.map[char]!, style: style);
    } else {
      return Text(char, style: style);
    }
  }
}
```
```dart:vertical_rotated.dart
class VerticalRotated {
  static const map = {
    ' ': '　',
    '↑': '→',
    '↓': '←',
    '←': '↑',
    '→': '↓',
    '。': '︒',
    '、': '︑',
    'ー': '丨',
    '─': '丨',
    '-': '丨',
    'ｰ': '丨',
    '_': '丨 ',
    '−': '丨',
    '－': '丨',
    '—': '丨',
    '〜': '丨',
    '～': '丨',
    '／': '＼',
    '…': '︙',
    '‥': '︰',
    '︙': '…',
    '：': '︓',
    ':': '︓',
    '；': '︔',
    ';': '︔',
    '＝': '॥',
    '=': '॥',
    '（': '︵',
    '(': '︵',
    '）': '︶',
    ')': '︶',
    '［': '﹇',
    "[": '﹇',
    '］': '﹈',
    ']': '﹈',
    '｛': '︷',
    '{': '︷',
    '＜': '︿',
    '<': '︿',
    '＞': '﹀',
    '>': '﹀',
    '｝': '︸',
    '}': '︸',
    '「': '﹁',
    '」': '﹂',
    '『': '﹃',
    '』': '﹄',
    '【': '︻',
    '】': '︼',
    '〖': '︗',
    '〗': '︘',
    '｢': '﹁',
    '｣': '﹂',
    ',': '︐',
    '､': '︑',
  };
}
```

# コードの解説

### ①文字の描画
```dart
Widget _character(String char) {
  if (VerticalRotated.map[char] != null) {
    return Text(VerticalRotated.map[char]!, style: style);
  } else {
    return Text(char, style: style);
  }
}
```
<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/985195/e832dba0-47fd-afe3-1d1a-abde8d1cfe01.png" />

ここでは、赤で囲った部分（1文字分）を描画しています。

横書き時と縦書き時で向きや配置が変わるものについては、`vertical_rotated.dart`から描画時に置き換えています。
例）、 。 ー 「」など


### ②文章を縦書きで描画

```dart
Widget _textBox(Runes runes) {
  return Wrap(
    textDirection: TextDirection.rtl,
    direction: Axis.vertical,
    children: [
      for (var rune in runes)
        Row(
          children: [
            SizedBox(width: space),
            _character(String.fromCharCode(rune)),
          ],
        )
    ],
  );
}
```
<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/985195/b3e8889c-e823-6420-2f02-de4e540e2056.png" />

ここでは、改行された部分を区切りとして、文章を縦書きにして描画しています。
Wrap ウィジェットを使用することで Text を縦並びにすることができます。

String 型のテキストは runes プロパティから、1文字ずつ（Unicodeコードポイント）のリストとして取得することができます。
元に戻したい場合は `String.fromCharCode()` を使用します。

@megumu-u さんのコメントより、`SizedBox(width: space)`を使わなくとも、`style`に`TextStyle(letterSpacing: 20)`を指定することで引数を少なくできそうです。ありがとうございます！


### ③文章を配置

```dart
@override
Widget build(BuildContext context) {
  final splitText = text.split("\n");
  return Row(
    textDirection: TextDirection.rtl,
    children: [
      for (var s in splitText) _textBox(s.runes),
    ],
  );
}
```
<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/985195/37558a51-f246-b000-dc4b-65d0b2012084.png" />

最後に、縦書き文章を右から左（Right to Left）に配置します。
連続した複数の改行に対応したい場合は、`s.isEmpty` の場合に SizeBox 等で隙間を作るように分岐すると良いと思います。