Flutter, React Native(Expo), Xamarin.FormsでScrollViewに表示する画像とテキストをズーム(Pinch-in / out)する実装を比べる

Flutter, React Native(Expo), Xamarin.Formsで、振る舞いが同じアプリを作っているんですが、その時にそれぞれのフレームワークで、Scrollviewにある画像とテキスト(label)ズームする(pinch-in / out)場合はどのような実装になるのか調べました。Scrollviewなしのズームだけだと拡大した画像とテキストがアプリの全体を覆い尽くしてしまうので、Scrollviewを実装して拡大の表示範囲を制限しました。（この書き方がそれぞれのフレームワークの概念に対して、理解が合ってるのか自信がない。。。:worried:）
調べた時に「要点となる実装部分だけの記載」という記事が多くて、初心者にとっては実装する時に「このコードはどこに追加(置き換え)するんだろうか？」って思ったので、FlutterとReact Native(expo)はコピペで動作するように記載しました。Xamarin.Formsはコピペの後でnamespaceを書き換えてください。

# 留意事項
## その１
「こう実装すればこう動く、ってことが分かればそれでOK！」を方針に実装を進めた個人開発の内容を記載しましたので、「なぜその実装が必要なのか？」や「なぜその実装で動くのか？」は分かってない部分多数です。。。 :bow:

## その２
筆者は実戦経験が乏しい（というより、アプリ開発は個人開発を除いて、現場での開発が未経験。。）、なので、ご指摘は「ここは、こうした方が良いよ:wink:」くらいのノリでお願いします。:pray::pray::pray:

## その３
「HelloWorldはできたけど、他にはこんなことできたら嬉しいな〜」って思っていた時のことを思い出して書きました。そのため、強々エンジニアや超人エンジニアの方々はこのページを参考にしない方が良いと思います。:expressionless::sleepy: :cold_sweat:

※iOSとAndroidのバージョン
![iOS-13.3](https://img.shields.io/badge/iOS-13.3-brightgreen)![Android-29(API Level)](https://img.shields.io/badge/Android-29(API Level)-brightgreen)

## [補足その１]【Mac限定】iOSのSimulatorとAndroidのEmulatorでズームする方法

iOSのSimulatorとAndroidのEmulatorでズームする方法は以下を参考にしました。

iOSのSimulator：https://neos21.hatenablog.com/entry/2017/08/13/080000
AndroidのEmulator：https://developer.android.com/studio/run/emulator?hl=ja#navigate

- iOS Simulatorのズーム
    - ピンチイン/アウト：Option+タッチパッド移動
    - カーソル移動：Option+Shift+タッチパッド移動
- Android Emulatorのズーム
    - ピンチイン/アウト：Command+タッチパッド移動
    - カーソル移動：Command+Shift+タッチパッド移動

※Androidのカーソル移動は ~~テキトーにやってたら~~ iOSの方を参考にしたらできました。:kissing_smiling_eyes:

## [補足その2]長文のサンプル
テキストの長文サンプルは、以下を参考にしました。（こんな機能があるんだ〜〜、へ〜）
https://www.itmedia.co.jp/bizid/articles/0703/14/news079.html

# Flutter

以下の記事を参考に、FlutterでScrollviewの中で画像とテキストをズームする実装を行いました。
参照記事
・https://www.egao-inc.co.jp/programming/%E3%80%90flutter%E3%80%91%E7%AB%AF%E6%9C%AB%E7%B8%A6%E3%82%B5%E3%82%A4%E3%82%BA%E3%82%92%E8%B6%85%E3%81%88%E3%81%9F%E3%82%89%E8%87%AA%E5%8B%95%E3%81%A7%E3%82%B9%E3%82%AF%E3%83%AD%E3%83%BC%E3%83%AB/
・https://api.flutter.dev/flutter/widgets/InteractiveViewer-class.html

## 概要

ScrollviewはSingleChildScrollViewを利用して、その子要素にInteractiveViewer(ズームの機能)を実装します。さらにInteractiveViewerの子要素にImageとTextを実装しました。ライブラリの追加を行わずにできました。

## 実装
![Flutter-1.20.3](https://img.shields.io/badge/Flutter-1.20.3-brightgreen)

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

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            SingleChildScrollView(
              child: Center(
                child: InteractiveViewer(
                  boundaryMargin: EdgeInsets.all(20.0),
                  minScale: 0.1,
                  maxScale: 3.0,
                  child: Text(
                    '彼は背後にひそかな足音を聞いた。それはあまり良い意味を示すものではない。誰がこんな夜更けに、しかもこんな街灯のお粗末な港街の狭い小道で彼をつけて来るというのだ。人生の航路を捻じ曲げ、その獲物と共に立ち去ろうとしている、その丁度今。 彼のこの仕事への恐れを和らげるために、数多い仲間の中に同じ考えを抱き、彼を見守り、待っている者がいるというのか。',
                  ),
                ),
              ),
            ),
            SingleChildScrollView(
              child: Center(
                child: InteractiveViewer(
                  boundaryMargin: EdgeInsets.all(20.0),
                  minScale: 0.1,
                  maxScale: 3.0,
                  child: Image.network(
                    'https://picsum.photos/250?image=9',
                    width: 500,
                    height: 500,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
```

# React Native(Expo)
以下の記事を参考に、React Native(Expo)でScrollviewの中で画像とテキストをズームする実装を行いました。

参照記事
・https://docs.expo.io/versions/latest/react-native/scrollview/
・https://snack.expo.io/@ivandjl/react-native-zoomable-view
・https://www.npmjs.com/package/@dudigital/react-native-zoomable-view

## 概要

React Nativeで用意されているScrollViewに、ReactNativeZoomableViewを実装しました。ズーム(pnich-n / out)機能は他の方法でも実現できるようですが、これでうまく動いたのでこれを採用しました。。。
以下のコマンドでReactNativeZoomableViewを入れてから、実装を行います。

```
$ npm i @dudigital/react-native-zoomable-view
$ npm install
```

## 実装
![React Native cli-2.0.1](https://img.shields.io/badge/React Native cli-2.0.1-brightgreen)![Expo-3.23.3](https://img.shields.io/badge/Expo-3.23.3-brightgreen)

```javascript:App.js
import React from 'react';
import { StyleSheet, View, ScrollView, Image, Text, SafeAreaView } from 'react-native';
import ReactNativeZoomableView from '@dudigital/react-native-zoomable-view/src/ReactNativeZoomableView';

export default class App extends React.Component {

  constructor(props) {
    super(props);
  }

  render() {
    return (
      <View>
        <SafeAreaView style={styles.container} />
        <ScrollView
          contentContainerStyle={styles.scrollView}
        >
          <View style={styles.modalContainer}>
          <ReactNativeZoomableView
              maxZoom={3.0}
              minZoom={0.5}
              zoomStep={0.5}
              initialZoom={1}
              bindToBorders={true}
              onZoomAfter={this.logOutZoomState}
              style={{
                padding: 10,
                backgroundColor: "red",
              }}
            >
              <Text
                style={{ width: 200, height: 200 }}
              >
                彼は背後にひそかな足音を聞いた。それはあまり良い意味を示すものではない。誰がこんな夜更けに、しかもこんな街灯のお粗末な港街の狭い小道で彼をつけて来るというのだ。人生の航路を捻じ曲げ、その獲物と共に立ち去ろうとしている、その丁度今。 彼のこの仕事への恐れを和らげるために、数多い仲間の中に同じ考えを抱き、彼を見守り、待っている者がいるというのか。それとも背後の足音の主は、この街に無数にいる法監視役で、強靭な罰をすぐにも彼の手首にガシャンと下すというのか。彼は足音が止まったことに気が着いた。あわてて辺りを見回す。ふと狭い抜け道に目が止まる。 彼は素早く右に身を翻し、建物の間に消え去った。その時彼は、もう少しで道の真中に転がっていたごみバケツに躓き転ぶところだった。 彼は暗闇の中で道を確かめようとじっと見つめた。どうやら自分の通ってきた道以外にこの中庭からの出道はないようだ。 足音はだんだん近づき、彼には角を曲がる黒い人影が見えた。彼の目は夜の闇の中を必死にさまよい、逃げ道を探す。もうすべては終わりなのか。すべての苦労と準備は水の泡だというのか。 突然、彼の横で扉が風に揺らぎ、ほんのわずかにきしむのを聞いた時、彼は背中を壁に押し付け、追跡者に見付けられないことを願った。この扉は望みの綱として投げかけられた、彼のジレンマからの出口なのだろうか。背中を壁にぴったり押し付けたまま、ゆっくりと彼は開いている扉の方へと身を動かして行った。この扉は彼の救いとなるのだろうか。
              </Text>
            </ReactNativeZoomableView>
          </View>
        </ScrollView>
        <ScrollView
          contentContainerStyle={styles.scrollView}
        >
          <View style={styles.modalContainer}>
          <ReactNativeZoomableView
              maxZoom={3.0}
              minZoom={0.5}
              zoomStep={0.5}
              initialZoom={1}
              bindToBorders={true}
              onZoomAfter={this.logOutZoomState}
              style={{
                padding: 10,
                backgroundColor: "red",
              }}
            >
              <Image
                style={{ width: 200, height: 200 }}
                source={{ uri: "https://picsum.photos/250?image=9" }}
              />
            </ReactNativeZoomableView>
          </View>
        </ScrollView>
      </View>
    );
    }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
  },
  modalContainer: {
    width: 300,
    height: 200,
    margin: 20,
  },
  scrollView: {
    backgroundColor: 'green',
    marginHorizontal: 20,
  },
});
```

# Xamarin.Forms
以下の記事を参考に、Xamarin.FormsでScrollviewの中で画像とテキスト(Label)をズームする実装を行いました。

参照記事
・http://www.xamboy.com/2017/08/02/creating-a-zoomable-scrollview-in-xamarin-forms/
・https://docs.microsoft.com/ja-jp/xamarin/xamarin-forms/user-interface/layouts/scrollview

## 概要

今回採用した方法では、画像とテキストをズームするという処理をiOS側とAndroid側でそれぞれ別に実装する必要があります。その上でPCL側にscrollViewとImageとLabelを実装します。
（Scrollview + Pinch-in/out + Image&Labelの実装をPCLだけで行う方法をご存知の方、どうかその方法を教えてください。ど〜うか！:pray::pray:）

## 実装
![Xamarin.Forms-4.6.0.1141](https://img.shields.io/badge/Xamarin.Forms-4.6.0.1141-brightgreen)

iOS側にZoomScrollViewRenderer.csを新規作成します。[プロジェクト名].iOSの直下に「ZoomScrollViewRenderer.cs」ファイルを新規作成して、以下のコードを記述します。

```C#:ZoomScrollViewRenderer.cs
using System.Linq;
using UIKit;
using xamarin.forms_project.iOS;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;

[assembly: ExportRenderer(typeof(ScrollView), typeof(ZoomScrollViewRenderer))]
namespace xamarin.forms_project.iOS
{
    public class ZoomScrollViewRenderer : ScrollViewRenderer
    {
        public ZoomScrollViewRenderer()
        {
        }

        // bool zoomEnabled = false;
        protected override void OnElementChanged(VisualElementChangedEventArgs e)
        {
            base.OnElementChanged(e);
            MaximumZoomScale = 3f;
            MinimumZoomScale = 1.0f;

        }

        public override void LayoutSubviews()
        {
            base.LayoutSubviews();

            if (Subviews.Length > 0)
            {
                ViewForZoomingInScrollView += GetViewForZooming;
            }
            else
            {
                ViewForZoomingInScrollView -= GetViewForZooming;
            }

        }

        public UIView GetViewForZooming(UIScrollView sv)
        {
            return this.Subviews.FirstOrDefault();
        }
    }
}
```

Anroid側にZoomScrollViewRenderer.csを新規作成します。[プロジェクト名].Androidの直下に「ZoomScrollViewRenderer.cs」ファイルを新規作成して、以下のコードを記述します。

```C#:ZoomScrollViewRenderer.cs
using System;
using Android.Views;
using Android.Views.Animations;
using xamarin.forms_project.Droid;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
using static Android.Views.ScaleGestureDetector;

[assembly: ExportRenderer(typeof(Xamarin.Forms.ScrollView), typeof(ZoomScrollViewRenderer))]
namespace xamarin.forms_project.Droid
{
    public class ZoomScrollViewRenderer : ScrollViewRenderer, IOnScaleGestureListener
    {
        private float mScale = 1f;
        private ScaleGestureDetector mScaleDetector;

        [Obsolete]
        public ZoomScrollViewRenderer()
        {
        }

        protected override void OnElementChanged(VisualElementChangedEventArgs e)
        {

            base.OnElementChanged(e);
            mScaleDetector = new ScaleGestureDetector(Context, this);

        }

        public override bool DispatchTouchEvent(MotionEvent e)
        {
            base.DispatchTouchEvent(e);
            return mScaleDetector.OnTouchEvent(e);
        }

        public bool OnScale(ScaleGestureDetector detector)
        {
            float scale = 1 - detector.ScaleFactor;

            float prevScale = mScale;
            mScale += scale;

            if (mScale < 0.5f) // Minimum scale condition:
                mScale = 0.5f;

            if (mScale > 1f) // Maximum scale condition:
                mScale = 1f;
            ScaleAnimation scaleAnimation = new ScaleAnimation(1f / prevScale, 1f / mScale, 1f / prevScale, 1f / mScale, detector.FocusX, detector.FocusY);
            scaleAnimation.Duration = 0;
            scaleAnimation.FillAfter = true;
            StartAnimation(scaleAnimation);
            return true;
        }

        public bool OnScaleBegin(ScaleGestureDetector detector)
        {
            return true;
        }

        public void OnScaleEnd(ScaleGestureDetector detector)
        {

        }
    }
}
```

MainPage.xamlに以下を記述します。

```xml:MainPage.xaml
<?xml version="1.0" encoding="utf-8"?>
<ContentPage
    xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:ios="clr-namespace:Xamarin.Forms.PlatformConfiguration.iOSSpecific;assembly=Xamarin.Forms.Core"
    ios:Page.UseSafeArea="true"
    x:Class="xamarin.forms_project.MainPage">
    <StackLayout>
        <ScrollView
            WidthRequest="800"
            HeightRequest="600">
            <StackLayout>
                <Label>
                    彼は背後にひそかな足音を聞いた。それはあまり良い意味を示すものではない。誰がこんな夜更けに、しかもこんな街灯のお粗末な港街の狭い小道で彼をつけて来るというのだ。人生の航路を捻じ曲げ、その獲物と共に立ち去ろうとしている、その丁度今。 彼のこの仕事への恐れを和らげるために、数多い仲間の中に同じ考えを抱き、彼を見守り、待っている者がいるというのか。それとも背後の足音の主は、この街に無数にいる法監視役で、強靭な罰をすぐにも彼の手首にガシャンと下すというのか。彼は足音が止まったことに気が着いた。あわてて辺りを見回す。ふと狭い抜け道に目が止まる。 彼は素早く右に身を翻し、建物の間に消え去った。その時彼は、もう少しで道の真中に転がっていたごみバケツに躓き転ぶところだった。 彼は暗闇の中で道を確かめようとじっと見つめた。どうやら自分の通ってきた道以外にこの中庭からの出道はないようだ。 足音はだんだん近づき、彼には角を曲がる黒い人影が見えた。彼の目は夜の闇の中を必死にさまよい、逃げ道を探す。もうすべては終わりなのか。すべての苦労と準備は水の泡だというのか。 突然、彼の横で扉が風に揺らぎ、ほんのわずかにきしむのを聞いた時、彼は背中を壁に押し付け、追跡者に見付けられないことを願った。この扉は望みの綱として投げかけられた、彼のジレンマからの出口なのだろうか。背中を壁にぴったり押し付けたまま、ゆっくりと彼は開いている扉の方へと身を動かして行った。この扉は彼の救いとなるのだろうか。
                </Label>
            </StackLayout>
        </ScrollView>
        <ScrollView
            WidthRequest="800"
            HeightRequest="600">
            <StackLayout>
                <Image Source="https://picsum.photos/250?image=9" HeightRequest="500" WidthRequest="500" />
            </StackLayout>
        </ScrollView>
    </StackLayout>
</ContentPage>
```