FlutterでWebサイトのメタデータ(OGP)を取得して表示する

# 背景
たまに見かける、URLを入れるだけでタイトルやらサムネイル画像やらが自動でつく現象、あれはいったいどうやっているんだろうと思い、調査・実装してみたくなった。

# 作りたいもの
* URLを入力して送信ボタンを押すと、指定したWebサイトのタイトルやサムネイル画像(？)を取得して表示するアプリ

# 作ったもの
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">WebサイトのOGPを抽出して表示するFlutterアプリ改 <a href="https://t.co/ZxaxJjADPJ">pic.twitter.com/ZxaxJjADPJ</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1267061774752202753?ref_src=twsrc%5Etfw">May 31, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

ソースコード: https://github.com/popy1017/flutter_fetch_ogp

# 前提知識（Flutter関係ないけど重要）
## `<meta>`タグとOGP(Open Graph Protcol)
初めは、HTML要素全部引っ張ってきて、タグを見て画像とか愚直に引っ張り出してるのかな〜(無知)と思ったけど、
どうやらhtmlで見かける`<meta>`タグから引っ張ってこれるらしい。
普通html書く時によく使うのは、こういうやつ。

```html
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
```

`<meta>`タグには他にもいろいろ設定できて、例えば検索結果の各サイト名の下に表示される説明文なんかも`<meta>`タグで設定できる。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/037c575f-41d4-6b41-f719-f2229db72423.png" width="500px" />

あれ？じゃあ画像とかもここで設定しているのでは・・・？(無知)と思いさらに調査を進めると、どうやらFacebookやTwitterなどのSNS用に、OGP(Open Graph Protcol) という規格が用意されているらしい。これこれぇ！！

>OGPとは、「Open Graph Protcol」の略でFacebookやTwitterなどのSNSでシェアした際に、設定したWEBページのタイトルやイメージ画像、詳細などを正しく伝えるためのHTML要素です。
<img src="https://digitalidentity.co.jp/blog/wp/wp-content/uploads/2018/04/%E7%84%A1%E9%A1%8C.png" />
引用元: https://digitalidentity.co.jp/blog/seo/ogp-share-setting.html

LineでURLを貼った時にたまに、上の図みたいに画像とかタイトルとかつかないな〜と思っていたが、OGPが設定されていないURLだったんだ！という気付きを得て、ようやくアプリの実装へ。。。

# (調査記録)Flutter（Dart）でWebサイトのmetaタグをとってくる
アプリを実装しようと思ったが、Dartでどうやってmetaタグをとってくるんだ？という疑問をまず解消してから先に進むことにした。
単純に考えれば、HTML要素全部取得して、正規表現とかでOGPの部分だけとってくれば良いのでは、それなら実装もできそう！と思ったが、
(誰かパッケージ化してくれているのでは。。。？)という淡い期待を抱きつつ調べると、あった！！
（ちなみに、Flutterのパッケージ公開サイトで"ogp"と調べた。）

[metadata_fetch | Dart Package](https://pub.dev/packages/metadata_fetch)
ソースコード見た感じでは、やはり正規表現で抜き出しているっぽいので、頑張れば自分で実装できそう。

動作確認用のプロジェクトでいろいろ試して(割愛)、いざアプリ開発へ。。。
今回は以下の実装を参考にした。

```dart:Get_Open_Graph_Metadata
import 'package:metadata_fetch/metadata_fetch.dart';
import 'package:http/http.dart' as http;

void main () async {

  // Makes a call
  var response = await http.get('https://flutter.dev');

  // Covert Response to a Document. The utility function `responseToDocument` is provided or you can use own decoder/parser.
  var document = responseToDocument(response);

  // get metadata
  var data = MetadataParser.OpenGraph(document);
  print(data);
}
```


# 流れ
大まかな流れは以下の通り。
1. まずは画面を設計・実装する
2. アプリ全体で管理する状態(メタデータ)を追加する
3. ボタンを押したときの動作(OGPをとってきて、上記の状態に保存する)を実装する
4. 細部にこだわる

## 1. 画面の設計・実装
### レイアウト案
今回はOGPの共通項目で一般的に(？)使われている、title, image, descriptionを取得して表示することとする。
ざっくりとした画面案は以下の通り。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/a13c9892-4f3c-5594-5af8-7901efc8610a.png" width="400px" />

### 実装
まずは、`flutter create`で作ったカウンターアプリを、以下のようにシンプルに書き換える。

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

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter OGP Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MetaDataView(),
    );
  }
}

class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Center(
          child: Text("hello"),
        ));
  }
}
```
この状態から、`MetaDataView`をいじってレイアウトを作っていく。

#### 大雑把なレイアウト
まず、大きくわけて2つの部分(メタデータ部分、Form(入力欄とボタン)部分)を縦に並べたいので、`Column`を使う。
次に、URL入力欄のところは固定の高さにしたいので、`SizedBox`を使って高さを指定し、残りの部分にメタデータ部分を表示する。

```dart:MetaDataView
class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Column(
          children: <Widget>[
            // わかりやすいように背景に色をつけている
            Expanded(child: Container(color: Colors.red)),
            SizedBox(height: 100),
          ],
        ));
  }
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/56a4e877-ddfd-1e99-a4f3-d825a75de04e.png" width="300px" />

#### メタデータ部分(タイトル、画像、説明文)
次に、メタデータ部分に含めたい要素(タイトル、画像、説明文)を追加していく。
ここでもこの要素を縦に並べたいので`Column`を使う。
まだメタデータを取得する部分を実装していないので、例としてFlutterの公式ページのメタデータを表示する(ここはなんでも良い)。
`Image.network(srcUrl)`で、ネット上の画像を簡単に表示できる。

追記:
実機で動作確認したところ、`TextField`にフォーカスしたときにキーボードが出てくる影響で、
レンダーボックスがオーバーフローしてしまうので、ここではスクロール可能な`ListView`を使った方が良いです
実機検証大事。。。

```dart:MetaDataView
class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Column(
          children: <Widget>[
            Expanded(
                child: Column(
              children: <Widget>[
                Text("Flutter - Beautiful native apps in record time"),
                Image.network(
                    "https://flutter.dev/images/flutter-logo-sharing.png"),
                Text(
                    "Flutter is Google's UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.  Flutter works with existing code, is used by developers and organizations around the world, and is free and open source.")
              ],
            )),
            SizedBox(height: 100, child: Container(color: Colors.cyan)),
          ],
        ));
  }
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/36c39599-2266-3f2b-fd87-20a2e9335ed9.png" width="300px" />
この段階では、表示される文字が端に近すぎるなどややおかしな点はあるが、そのあたりのこだわりは最後に実装する(自己暗示)。

#### Form部分(URL入力欄、Fetchボタン)
次にForm部分を構成する要素を追加する。
ここでも、要素を縦に並べたいので`Column`を使う。
入力欄は`TextField`(or `TextFormField`)で、ボタンは`RaisedButton`で表示できる。

```dart:MetaDataView
class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Column(
          children: <Widget>[
            ~~~~,
            SizedBox(
                height: 100,
                child: Column(
                  children: <Widget>[
                    TextField(),
                    RaisedButton(
                      onPressed: () {},
                      child: Text("Fetch"),
                      color: Colors.blue,
                      textColor: Colors.white,
                    ),
                  ],
                )),
          ],
        ));
  }
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/64fe97f9-f43a-f68e-e37a-d479074ec409.png" width="300px" />
とりあえず大雑把なレイアウトは完成。
...

やっぱりちょっと気になるので、全体をPaddingでラップする。

```dart:MetaDataView
class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Padding( // <= 我慢できずPaddingを追加
          padding: const EdgeInsets.all(12.0),
          child: Column(
            children: <Widget>[
              Expanded(
                  child: Column(
                ~~~
```

#### 各要素をWidgetに切り出す
このまま進めていくとネストがどんどん深くなり可読性が下がるので、ある程度まとまった単位でWidgetに切り出したい。
今回は、メタデータ部分を`MetaDataDetail`に、Form部分を`FetchOgpForm`に切り出す。
まずは切り出し先のファイルを作成する。(`lib/`配下に`components/`を新規作成)

* `lib/components/metadata_detail.dart`,
* `lib/components/fetch_ogp_form.dart`,

```dart:metadata_detail.dart
import 'package:flutter/material.dart';

class MetadataDetail extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Expanded(
        child: Column(
      children: <Widget>[
        Text("Flutter - Beautiful native apps in record time"),
        Image.network("https://flutter.dev/images/flutter-logo-sharing.png"),
        Text(
            "Flutter is Google's UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.  Flutter works with existing code, is used by developers and organizations around the world, and is free and open source.")
      ],
    ));
  }
}
```
```dart:fetch_ogp_form.dart
import 'package:flutter/material.dart';

class FetchOgpForm extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SizedBox(
        height: 100,
        child: Column(
          children: <Widget>[
            TextField(),
            RaisedButton(
              onPressed: () {},
              child: Text("Fetch"),
              color: Colors.blue,
              textColor: Colors.white,
            ),
          ],
        ));
  }
}
```
切り出し終わったら、MetaDataViewを書き換えて切り出したWidgetを表示する。
※ ここでメタデータの英語は`metadata`であることに気づいたので、dを大文字にするのをやめて、`MetaDataView`を`MetadataView`に変更しました。

```dart:main.dart_MetadataView
class MetadataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Padding(
          padding: const EdgeInsets.all(12.0),
          child: Column(
            children: <Widget>[MetadataDetail(), FetchOgpForm()],
          ),
        ));
  }
}
```


## 2. 状態管理(メタデータ、テキスト)を追加する
このアプリで管理する必要がある状態は、以下の2つ。

* URL入力欄に入力された文字列
* 取得したメタデータ

URL入力欄に入力された文字列は、ボタンを押したときに参照できれば良いので`FetchOgpForm`で管理する。
取得したメタデータは、ぱっと見`MetadataDetail`のみから参照されそうだが、Fetchボタンを押したときに保持しているメタデータを更新したいので、`FetchOgpForm`からもアクセスできるようにする必要がある。そのため、2つのWidgetの親である`MetadataView`(かそれより上)で状態を管理する。
(もっと良い設計があるかもしれない。)

### URL入力欄に入力された文字列の状態を管理する
URL入力欄に入力された文字列は、1つのWidget(`FetchOgpForm`)でしか必要ないため、`StatefulWidget`で状態を管理する。
そのため`FetchOgpForm`を書き換え、以下のようにする。

```dart:fetch_ogp_form.dart
import 'package:flutter/material.dart';

class FetchOgpForm extends StatefulWidget {
  @override
  _FetchOgpFormState createState() => _FetchOgpFormState();
}

class _FetchOgpFormState extends State<FetchOgpForm> {
  String _url = "";

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        height: 100,
        child: Column(
          children: <Widget>[
            TextField(
              onChanged: (text) {
                setState(() {
                  _url = text;
                });
              },
            ),
            RaisedButton(
              onPressed: () {
                print("Current url is $_url");
                // fetchMetadata(_url);
              },
              child: Text("Fetch"),
              color: Colors.blue,
              textColor: Colors.white,
            ),
          ],
        ));
  }
}
```
これで、ボタン押した時にデバッグコンソールに入力された文字列が表示されるはず。

### 取得したメタデータの状態を追加する
上述の通り、このアプリで保持するメタデータは、`FetchOgpForm`から更新し、`MetadataDetail`で参照する必要があるため、それらの上のWidgetで管理する必要がある。
Flutterの状態管理は、上で使った`StatefulWidget`や、BLoCパターンなど様々な種類があるが、ここでは今流行り？の`Provider`パターンを使う。
(最近だと`state_notifier`が出てきているが、使い所や`Provider`との違い、優位点が理解できていない。)

#### provider, metadata_fetchのインストール
Providerパターンでは、`provider`パッケージを使うので、`pubspec.yaml`を編集してインストールする。
`metadata_fetch`パッケージをここでインストールする理由は、`metadata_fetch`で定義されている`Metadata`クラスを使うため。

```yaml:pubspec.yaml
~~
dependencies:
  flutter:
    sdk: flutter

  provider: ^4.1.2

  metadata_fetch: ^0.3.1
~~
```
必要があれば`flutter pub get`を実行する。

#### 状態クラスを作る
`ChangeNotifier`を使って、状態を表すクラスを作る。
`lib/`配下に`models/`を作って、`metadata_model.dart`を新規作成。
まだ実際に指定したWebサイトからメタデータを取得する部分はないので、確認用にモックデータを定義。

```dart:lib/models/metadata_model.dart
import 'package:flutter/material.dart';
import 'package:metadata_fetch/metadata_fetch.dart';

// テスト用。あとで消す。
Map<String, dynamic> mockJson = {
  "title": "Flutter - Beautiful native apps in record time",
  "image": "https://flutter.dev/images/flutter-logo-sharing.png",
  "description":
      "Flutter is Google's UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.  Flutter works with existing code, is used by developers and organizations around the world, and is free and open source"
};

class MetadataModel extends ChangeNotifier {
  Metadata ogp = Metadata.fromJson(mockJson);
  // fetchOgpFrom(String url) {};
}

```

#### 各Widgetが状態クラスにアクセスできるようにする
`ChangeNotifierProvider`を使って、`MetadataDetail`widgetと`FetchOgpForm`widgetが上記の状態クラスにアクセスできるようにする。
(`ChangeNotifier`をprovideする)

```dart:main.dart_MetadataView
class MetadataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Padding(
          padding: const EdgeInsets.all(12.0),
          child: ChangeNotifierProvider(
            create: (context) => MetadataModel(),
            child: Column(
              children: <Widget>[MetadataDetail(), FetchOgpForm()],
            ),
          ),
        ));
  }
}
```
#### MetadataDetailから状態クラスMetadataModelを参照する
状態変化の監視つきで状態を取得する場合は、`context.select`を使う。(provider: 4.1.0以降)

```dart:metadata_detail.dart
import 'package:fetch_ogp/models/metadata_model.dart';
import 'package:flutter/material.dart';
import 'package:metadata_fetch/metadata_fetch.dart';
import 'package:provider/provider.dart';

class MetadataDetail extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final Metadata _ogp = context.select((MetadataModel _model) => _model.ogp);

    return Expanded(
        child: Column(
      children: <Widget>[
        Text(_ogp.title),
        Image.network(_ogp.image),
        Text(_ogp.description)
      ],
    ));
  }
}
```
これで、変化が監視されたメタデータを表示できるようになった。

## 3. ボタンを押したときの動作を実装する
ここで行いたい処理は、`FetchOgpForm`から状態クラス`MetadataModel`にアクセスし、`Metadata ogp`を更新することである。
`onPressed`の中で更新を行っても良いが、更新用の関数を`MetadataModel`に定義してそれを呼び出した方が楽そうなのでそうする。

まずは指定したURLからHTML要素をとってくる必要があるので、`http`パッケージをインストールする。

```yaml:pubspec.yaml
dependencies:
  ~~
  http: ^0.12.1
```

### OGPを取得する関数を定義する
MetadataModelに、OGPを取得して`Metadata ogp`にセットする関数を書いていく。
(各行でエラー起こる予感がするが、いったんエラー処理は割愛)
`http.get()`は非同期(`Future`を返す)関数なので、`await`で待たせてもらう。

```dart:metadata_model.data_MetadataModel
class MetadataModel extends ChangeNotifier {
  Metadata ogp = Metadata.fromJson(mockJson);

  void fetchOgpFrom(String _url) async {
    final response = await http.get(_url);
    final document = responseToDocument(response);
    ogp = MetadataParser.OpenGraph(document);
    notifyListeners();
  }
}
```

### OGPを取得する関数を呼び出す
上記で定義した関数を`FetchOgpForm`widgetから呼び出したい。
ボタンを押した時の動作は、`FetchOgpForm`の`RaisedButton`の`onPressed`で定義する。
ちなみに、`onPressed`が`null`のときはボタンが非活性になるので、入力状況によって活性・非活性を切り替えることができる。
状態クラスの関数にアクセスするためには、`context.read<ModelName>().functionName()`を使う。

※ FromとFormがややこしいので別の名前にすれば良かった

```dart:fetch_ogp_form.dart/_FetchOgpFormState
class _FetchOgpFormState extends State<FetchOgpForm> {
  String _url = "";

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        height: 100,
        child: Column(
          ~~
            RaisedButton(
              onPressed: (_url == "")
                  ? null
                  : () {
                      print("Current url is $_url");
                      context.read<MetadataModel>().fetchOgpFrom(_url);
                    },
              child: Text("Fetch"),
              color: Colors.blue,
              textColor: Colors.white,
            ),
          ],
        ));
  }
}
```
これで、とりあえず入力したURLからメタデータをとってきて表示することができた。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">WebサイトのOGPを抽出して表示するFlutterアプリ <a href="https://t.co/WvMVxC1HVp">pic.twitter.com/WvMVxC1HVp</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1266995036836597760?ref_src=twsrc%5Etfw">May 31, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 4. 細部にこだわる
### タイトルをタイトルっぽくする
現状では、文字が小さくてタイトル感がないので、スタイルを調整してタイトルっぽくしたい。
`Text`widgetにスタイルを付与するには、`style: TextStyle()`を追加する。

```dart:MetadataDetail/Text
  Text(
    _ogp.title,
    style: TextStyle(
      fontWeight: FontWeight.bold,
      fontSize: 24,
    ),
```

### ボタンをかっこよくする
#### ボタンを丸角にする
完全に好みだが、まるっぽいボタンの方が良いのでボタンを丸角にする。
ボタンを丸角にするためには、`RaisedButton`widgetに`shape: StadiumBorder(),`をつける。

```dart:fetch_ogp_form.dart/_FetchOgpFormState
~~
            RaisedButton(
              shape: StadiumBorder(),
              onPressed: (_url == "")
~~
```

#### ボタンにアイコンをつける
ボタンにアイコンをつけるためには、`RaisedButton.icon()`を使い、`icon: Icon()`を指定する。
またその際、文字列をアイコンの後に続ける場合は`child`ではなく`label`を使う必要がある必要がある点に注意。

```dart:fetch_ogp_form.dart/_FetchOgpFormState
~~
            RaisedButton.icon(
              shape: StadiumBorder(),
              icon: Icon(
                Icons.file_download,
                color: Colors.white,
              ),
              onPressed: (_url == "")
                  ? null
                  : () {
                      print("Current url is $_url");
                      context.read<MetadataModel>().fetchOgpFrom(_url);
                    },
              label: Text("Fetch"),
~~
```
### (割と雑に)エラーに対処する
#### OGPが設定されていないURLが指定された場合
今の実装では、タイトル、画像、説明文のいずれかがWebサイト側で設定されていない場合、`Text`や`Image`widgetに`null`が渡されてしまい、エラーとなってしまう。
そこでそれらの部分で、`??`(if null operator)を使って、`null`の場合は決められた文字列を指定するようにする。

```dart:metadata_detail.dart
// No Imageと書かれた画像のフリー素材
final noImagePath =
    "https://www.shoshinsha-design.com/wp-content/uploads/2016/10/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88-2016-10-05-0.41.12.png";

class MetadataDetail extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final Metadata _ogp = context.select((MetadataModel _model) => _model.ogp);

    return Expanded(
        child: Column(
      children: <Widget>[
        Text(
          _ogp.title ?? "No title",
          style: TextStyle(
            fontWeight: FontWeight.bold,
            fontSize: 24,
          ),
        ),
        Image.network(_ogp.image ?? noImagePath),
        Text(_ogp.description ?? "No description")
      ],
    ));
  }
}
```
#### OGP取得の際に何かしらのエラーがあった場合に通知する
現在の実装では、URL形式でない文字列が与えられた場合や、通信できなかったときなど、様々なエラーが発生した場合への処理が何も書かれていない。
そのため、エラーが起きたときに、SnackBarと呼ばれる画面下からひょいっと出てくる小窓みたいなのを追加し、エラーが起きたことを伝えるようにする。

処理が失敗したか成功したかわからないので、`fetchOgpFrom(url)`関数を書き換える。

```dart:metadata_model.dart
class MetadataModel extends ChangeNotifier {
  Metadata ogp = Metadata.fromJson(mockJson);

  Future<bool> fetchOgpFrom(String _url) async {
    try {
      final response = await http.get(_url);
      final document = responseToDocument(response);
      ogp = MetadataParser.OpenGraph(document);

      notifyListeners();
      return true;
    } catch (e) {
      print(e.message ?? e);
      return false;
    }
  }
}
```
失敗したときのみSnackBarを表示するよう、`FetchOgpForm`の`onPressed()`を書き換える。

```dart:fetch_ogp_form.dart
  onPressed: (_url == "")
    ? null
    : () async {
      print("Current url is $_url");
      final success = await context
        .read<MetadataModel>()
        .fetchOgpFrom(_url);

     if (!success) {
       final SnackBar _snackBar = SnackBar(
         content: Text("Error happened."),
         backgroundColor: Colors.red[300],
       );
       Scaffold.of(context).showSnackBar(_snackBar);
     }
   },
```

##### 余談
本当はエラー内容に応じてメッセージを変えたかったが、すぐにはできなさそうだったので別の機会にやることにした。

### URL入力欄にプレースホルダーを表示する
ぱっと見何を入力すれば良いのかわからないので、プレースホルダーでわかりやすくする。
`TextField`に`decoration: InputDecoration(~~)`を指定するといろいろ設定できる。

```dart:fetch_ogp_form.dart
class _FetchOgpFormState extends State<FetchOgpForm> {
  String _url = "";

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        height: 100,
        child: Column(
          children: <Widget>[
            TextField(
              decoration: InputDecoration(hintText: "https://www.example.com"),
              onChanged: (text) {
                setState(() {
                  _url = text;
                });
              },
            ),
   ~~
```