Try .NET Core

.NET Coreを動かした、試した記録を書き残します。

音楽サーバ"Mopidy"のフロントエンドを作る:14 Linuxでハマりやすいところ

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第14回です。
完成版Mopidy.Finderのライブデモはこちら。

今回は、Asp.Net CoreアプリをLinuxで動かす際にハマるポイントを追っていきます。

ソケットは自動で破棄されない

C#の動作環境である.Net VMガベージコレクションが付いてます。
なので、インスタンスの破棄を意識しないで書いていても、それなりに動いてくれます。

しかし、LinuxのSocket周りには注意が必要です。

Windows機の場合は、参照されなくなったSocketはガベコレが回収してくれます。
ところがLinuxでは、意図して破棄しないと、Socketをすぐに使い切ってしまいます。

昨今のC#では、TCP/UDPのSocketを生成する場面は少ないかもしれません。
しかし、例えばHttpClient
外部のWebサービスを使う際に便利な実装ですが、内部的にはSocketが生成されます。

これをDisposeしないまま放置しておくと、LinuxではやがてSocketを使い切ってしまい、接続出来なくなってしまいます。

例えば、こんな検証コードを書いてみました。

public class Program
{
    // Set your Mopidy Address
    private const string MopidyRpcUrl = "http://192.168.254.251:6680/mopidy/rpc";

    private static int _count = 0;

    public static void Main(string[] args)
    {
        var withUsing = false;
        if (args.Contains("--withoutusing"))
            withUsing = false;
        else if (args.Contains("--withusing"))
            withUsing = true;

        Console.WriteLine("Start Socket Overflow Test: " + ((withUsing) ? "with Using" : "without Using"));

        while (true)
        {
            try
            {
                Program._count++;

                var sendJson = $"{{\"jsonrpc\": \"2.0\", \"method\":\"core.playback.get_state\", \"id\": {Program._count}}}";
                var content = new StringContent(sendJson, Encoding.UTF8, "application/json");
                content.Headers.ContentType = new MediaTypeHeaderValue("application/json");

                Console.WriteLine("");
                Console.WriteLine("");
                Console.WriteLine($"Try {((withUsing) ? "with Using" : "without Using")} Count: " + Program._count.ToString());
                Console.WriteLine("Send Message: " + sendJson);

                var result = (withUsing)
                    ? Program.GetResultWithUsing(content)
                    : Program.GetResultWithoutUsing(content);

                Console.WriteLine("Result: " + result);

                Task.Delay(200).GetAwaiter().GetResult();
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Exception!");
                Program.DumpException(ex);

                Task.Delay(1000).GetAwaiter().GetResult();
            }
        }
    }

    private static string GetResultWithUsing(StringContent content)
    {
        using (var client = Program.GetHttpClient())
        {
            var message = client.PostAsync(Program.MopidyRpcUrl, content).GetAwaiter().GetResult();
            var result = message.Content.ReadAsStringAsync().GetAwaiter().GetResult();

            return result;
        }
    }

    private static string GetResultWithoutUsing(StringContent content)
    {
        var client = Program.GetHttpClient();

        var message = client.PostAsync(Program.MopidyRpcUrl, content).GetAwaiter().GetResult();
        var result = message.Content.ReadAsStringAsync().GetAwaiter().GetResult();

        return result;
    }

    private static HttpClient GetHttpClient()
    {
        var result = new HttpClient();
        result.DefaultRequestHeaders.Accept.Clear();
        result.DefaultRequestHeaders.Accept.Add(
            new MediaTypeWithQualityHeaderValue("application/json")
        );
        result.DefaultRequestHeaders.Add("User-Agent", ".NET Foundation Repository Reporter");
        result.Timeout = TimeSpan.FromMilliseconds(1000);

        return result;
    }

    private static void DumpException(Exception ex)
    {
        Console.WriteLine("----------------------------------------");
        Console.WriteLine($"Message: {ex.Message}");
        Console.WriteLine($"StackTrace: {ex.StackTrace}");
        if (ex.InnerException != null)
            Program.DumpException(ex.InnerException);
    }
}

MopidyのJSON-RPCに、単純なクエリを繰り返すコードです。
引数を"--withusing"とすると、HttpClientをusingでラップして破棄するように。
"--withouusing"とすると、破棄しないで放置するようにしています。

このコードで、linux用バイナリを生成します。

# dotnet publish -c Release -r linux-x64

これをUbuntu18.0.4LTSで実行してみます。
ミニマルインストール後に、SSHと、実行ファイルコピー用のSambaを入れました。

まず、"--withoutusing"スイッチを付けて実行してみると...
f:id:try_dot_net_core:20190815164054p:plain

接続回数1004回目で、落ちてしまいます。
f:id:try_dot_net_core:20190815164204p:plain

CentOS7.6でも試してみます。
f:id:try_dot_net_core:20190815170330p:plain やはり、1004回目で落ちますね。
f:id:try_dot_net_core:20190815164501p:plain

そして、"--withusing"スイッチを付けて実行してみると。
Ubuntu18では...
f:id:try_dot_net_core:20190815165008p:plain 問題なし、です。

CentOS7.6でも、問題ありません。
f:id:try_dot_net_core:20190815164810p:plain

Windowsでも実行してみます。
まずば実行バイナリの生成。

# dotnet publish -c Release -r win-x64

そして、Linuxでは落ちてしまう"--withoutusing"スイッチを付けて実行すると... f:id:try_dot_net_core:20190815165157p:plain

こちらは、問題なく動き続けてしまいます。
f:id:try_dot_net_core:20190815165233p:plain

このように、Windowsでは動作に問題ないコードであっても、Linuxでは動かなくなることがあるんですね。
C#で通信している箇所は要注意、と見ておいたほうが良いでしょう。

一部NuGetパッケージのバージョン暗黙解釈に失敗する

下記は、Asp.NetCore2.2-MVCプロジェクト生成直後のcsprojファイルです。

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" />
  </ItemGroup>
</Project>

Microsoft.AspNetCore.Appに、バージョンが書かれていないんですね。
しかし、NuGetパッケージマネージャで見てみると。
f:id:try_dot_net_core:20190815172408p:plain
ここではきちんと、v2.2.0と表記があります。

どうやら、バージョンを暗黙的に解釈しているようです。

しかしこのままコードを作り進み、Linuxに持って行ってpublishしてみると...

[umuser@ume01srv tmp]$ dotnet publish -c Release -r linux-x64
Microsoft (R) Build Engine version 16.1.76+g14b0a930a7 for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj : error NU1605: Detected package downgrade: Microsoft.EntityFrameworkCore from 2.2.6 to 2.2.4. Reference the package directly from the project to select a different version.  [/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.sln]
/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj : error NU1605:  MopidyFinder -> Microsoft.AspNetCore.App 2.2.6 -> Microsoft.EntityFrameworkCore (>= 2.2.6 && < 2.3.0)  [/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.sln]
/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj : error NU1605:  MopidyFinder -> Microsoft.EntityFrameworkCore (>= 2.2.4) [/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.sln]
/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj : error NU1605: Detected package downgrade: Microsoft.EntityFrameworkCore.Design from 2.2.6 to 2.2.4. Reference the package directly from the project to select a different version.  [/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.sln]
/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj : error NU1605:  MopidyFinder -> Microsoft.AspNetCore.App 2.2.6 -> Microsoft.EntityFrameworkCore.Design (>= 2.2.6 && < 2.3.0)  [/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.sln]
/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj : error NU1605:  MopidyFinder -> Microsoft.EntityFrameworkCore.Design (>= 2.2.4) [/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.sln]
/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj : error NU1605: Detected package downgrade: Microsoft.EntityFrameworkCore.Tools from 2.2.6 to 2.2.4. Reference the package directly from the project to select a different version.  [/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.sln]
/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj : error NU1605:  MopidyFinder -> Microsoft.AspNetCore.App 2.2.6 -> Microsoft.EntityFrameworkCore.Tools (>= 2.2.6 && < 2.3.0)  [/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.sln]
/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj : error NU1605:  MopidyFinder -> Microsoft.EntityFrameworkCore.Tools (>= 2.2.4) [/mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.sln]
  Restore failed in 1.13 sec for /mnt/sdb/private/Work/DoBes/tmp/MopidyFinder.csproj.

こんなふうに、NuGetパッケージの整合性が取れずにエラーになることがあります。

この場合は、csprojファイル上のMicrosoft.AspNetCore.Appパッケージに、バージョンを書き加えます。

<PackageReference Include="Microsoft.AspNetCore.App" />

 ↓

<PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.0" />

これでpublishが通るようになりました。

今のところ、Microsoft.AspNetCore.App以外で、バージョン表記が無いという現象に行き当たったことはありません。
が、時々はcsprojファイルの中身もチェックしといた方が良さそうです。

パス文字列の大文字/小文字

これは言語に関わらず、出てくる問題ですね。
Webプログラミングをやっていれば必ず直面する問題のため、みなさま恐らく普段から気をつけていらっしゃると思います。

私も気を付けてはいたのですが...。
Mopidy.Finderでも、一度直面しました。こちらのコミットです。

キャメルケースのフォルダ名を「Sidebars」→「SideBars」と変更した際の現象です。
Windows上では「SideBars」に変わっていたのですが、WindowsのGitクライアントがその違いを認識できておらず。
Gitリポジトリ上ではずっと、「Sidebars」として保持されていました。

TypeScriptのコンパイルLinux上で実行することは滅多に無かったため、リリース直前まで気が付きませんでした。

いやはや、お恥ずかしい。

RaspberryPi(Raspbian)は32bitOS

最近のラズパイのCPUは既に64bit化されているのですが、OSはまだ32bit版なんですね。

CentOSUbuntuで動かしたバイナリをそのままコピペしてしまい、起動しない原因が分からずにしばらく悩みました。

Raspbianで動かすためのバイナリを作るには、publishの引数を下記のようにします。

# dotnet publish -c Release -r linux-arm

でも、乗り越えてしまえば

こまごまと問題に直面することも、あるとはいえ。
それらを乗り切ってしまえば、.Net Coreアプリは快調に動いてくれます。

最近のpublishの処理は、大変優秀です!
ミニマルインストールしたLinuxにバイナリをコピペするだけで、すんなりと動きます。

C#erとしては、まったく良い時代になったもんだ、としみじみ思います。

以上、Linuxのハマりポイントのおはなし、でした!

これで、予定していた記事を全て書き終えました。
またC#ネタが出てきたら、適当に書き連ねようと思います。
長々と駄文にお付き合いいただき、ありがとうございました!

音楽サーバ"Mopidy"のフロントエンドを作る:13 モバイルデバイスでデバッグする

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第13回です。
完成版Mopidy.Finderのライブデモはこちら。

今回は、iOSAndroidでのデバッグ方法を追っていきます。

iOSは、エミュレータでお手軽に

iOSでのデバッグは、macさえあれば、とてもお手軽に出来ます。
Xcodeをインストールすると、各種iOSバイスエミュレータが付いてきます。

まずはSafariを起動し、開発者ツールを有効にします。
Safariのメニューから、環境設定を選んで... f:id:try_dot_net_core:20190814194746p:plain

「詳細」メニューの一番下、「メニューバーに"開発"メニューを表示」にチェック。
f:id:try_dot_net_core:20190814194925p:plain

すると、Safariのメニューに"開発"が追加されます。
f:id:try_dot_net_core:20190814195149p:plain

次に、Xcodeを開きます。そしてエミュレータを起動。
f:id:try_dot_net_core:20190814195324p:plain

エミュレータ上のiOSが起動し終わると、こんな感じです。
f:id:try_dot_net_core:20190814195512p:plain

このエミュレータの中のSafariを起動して、デバッグするURLを入力します。
f:id:try_dot_net_core:20190814195619p:plain

デバッグするアプリの画面が出てきたところで...
f:id:try_dot_net_core:20190814195702p:plain

mac上のSafariに戻り、"開発"メニューから「Simulator」を選ぶと。
現在エミュレータで表示中のURLが出てきます。 f:id:try_dot_net_core:20190814195845p:plain

これを選択すれば、Safariの開発者ツールが出てきます。
f:id:try_dot_net_core:20190814200011p:plain

ツール上でDOMを選択すると、エミュレータ画面上でハイライトしてくれますね。
f:id:try_dot_net_core:20190814200133p:plain

お手軽ですね!
macを買わなきゃいけない」という、Windowsユーザーにとっては大変高いハードルを、乗り越えさえすれば...。
Appleさんは、ご自身のご商売をきちんと分かっていらっしゃいますねぇ。

Androidは、まず環境づくりから

一方、AndroidWindowsでもデバッグ可能です。
Androidの場合、エミュレータよりも実機を使う方が手軽です。
ただし、あらかじめAndroid SDKをインストールしておく必要があるようです。

以前の記事でVisual Studioのセットアップフローを書きましたが、Visual Studio Installerの機能選択時に「.NETによるモバイル開発」(=Xamarin)を選んでいる方は、既にインストールされています。
f:id:try_dot_net_core:20190814202403p:plain

もし入れていらっしゃらないようなら、Android Studioをインストールすると、GUISDK管理ツールも一緒に入ってきます。
ダウンロードはこちらから。
https://developer.android.com/studio/index.html?hl=ja

インストールして起動すると、起動画面の"Configure"メニューに、Android SDKの管理用GUIツールがあります。
f:id:try_dot_net_core:20190814203133p:plain

ここで、お手元のAndroid機のOSバージョンに合わせてSDKを選びます。
f:id:try_dot_net_core:20190814203518p:plain
選んでから、下の「Apply」ボタンを押すと、SDKがインストールされます。
f:id:try_dot_net_core:20190814203709p:plain

Android機の準備

開発PC側の準備が出来たら、次はAndroidバイスの準備です。
最近のAndroid機では標準で非表示の、「開発者向けオプション」を表示させます。

バイスの設定を開き、"端末情報"を選びます。 f:id:try_dot_net_core:20190814204812j:plain

その中の、"ソフトウェア情報"を選択。
f:id:try_dot_net_core:20190814204858j:plain

ここに出てくる、"ビルド番号"。これを、連打します。
f:id:try_dot_net_core:20190814205003j:plain

7回タップすると、開発者モードに切り替わります。
f:id:try_dot_net_core:20190814205155j:plain

設定メニューに戻ってみると、"開発者向けオプション"が表示されています。
f:id:try_dot_net_core:20190814205329j:plain

バイスのメーカーやOSバージョンによって、若干表記が異なります。
例えばこちらは、AmazonのFireTablet-2015年版。
設定メニューから、"端末オプション"に入ります。 f:id:try_dot_net_core:20190814205829j:plain

その中の、"シリアル番号"を連打すると、開発者モードになります。
f:id:try_dot_net_core:20190814210150j:plain

MediaPadM5では、"設定"→"システム"→"タブレット情報"の中の、"ビルド番号"でした。
f:id:try_dot_net_core:20190814211451j:plain

また古いAndroid機では、最初から"開発者向けオプション"が出ているものも。
f:id:try_dot_net_core:20190814210521j:plain

一度"開発者向けオプション"を出してしまえば、あとはどの端末でも同じです。
"開発者向けオプション"を開き、"USBデバッグ"のチェックをONにします。
f:id:try_dot_net_core:20190814211954j:plain

ONにするとき、こんな警告が出てきます。
f:id:try_dot_net_core:20190814212137j:plain

Android機で、いざデバッグ

Android機の準備が出来たところで、USBケーブルでPCにデバイスを繋げます。
最近のデバイスでは、PCに接続した際にドライバがインストールされます。

そして、デバイスChromeを起動し、デバッグするURLを開きます。
f:id:try_dot_net_core:20190814212811j:plain

次に、PCでChromeを起動します。
そしてURLに、下記を入力します。

chrome://inspect/#devices

すると、PCのChromeはこんな画面になります。
f:id:try_dot_net_core:20190814213138j:plain

またAndroid機の方では、こんな警告が出てきます。
f:id:try_dot_net_core:20190814213454j:plain

Android機の警告を許可すると、PC側のChromeで、デバイスがリストされます。 f:id:try_dot_net_core:20190814213651j:plain
※デバイスが出て来ない場合は、PCのChromeをリロードしてみてください。
※どうやってもデバイスが出て来ない場合は、ADBドライバが入っていないことが考えられます。
※その場合は、メーカーのドライバ配布ページからADBドライバを貰ってきてインストールしてください。

この中の「inspect」リンクをクリックすると... f:id:try_dot_net_core:20190814213928p:plain

はい!まいどお馴染みの、Chrome開発者ツールが出てきます! f:id:try_dot_net_core:20190814214159p:plain

FireTabletでは、AmazonアプリストアにChromeが出てきません。
仕方がないので、こちらから最新版のapkを貰ってきました。
https://androidapksfree.com/chrome/com-android-chrome/old/

本体に転送し、インストール。
f:id:try_dot_net_core:20190814215436j:plain

FireTabletをPCに接続し、Chromeを起動してみると。
f:id:try_dot_net_core:20190814215631j:plain
ばっちし、リモートデバッガが動きました。

XperiaVLはAndroid4.1と古いため、GooglePlayストアにChromeが出てきません。
やはり同じところから、今度は古めのChrome ver61.0のapkを貰ってきます。
またこちらはADBドライバが無かったため、PC側に発売当時のツールをインストール。
f:id:try_dot_net_core:20190814221131j:plain

すると、Android4.1機でもデバッガを認識してくれました。
f:id:try_dot_net_core:20190814221358j:plain

以上、モバイルデバイスデバッグしてみるおはなし、でした!

音楽サーバ"Mopidy"のフロントエンドを作る:12 Bootstrapテーマ導入とCSS軽量化

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第12回です。
完成版Mopidy.Finderのライブデモはこちら。

今回は、Bootstrapテーマ導入とCSS最適化の流れを追って行きます。

デザインセンスはひとにお任せ

まず。
私のスキルセットは若干フロントエンド寄りとはいえ、デザイナではありません。
自分のデザインセンスには、一切の信用を置いていません。

職業デザイナさんと仕事をするたびに、毎回感心させられます。
あの人たち、どうやってかっこよさと機能を両立させてるんでしょうね?

こういう自分に無いものは、他人様に求めるのが一番です。
そしてBootstrapには、そんなデザイナさんたちの能力の粋が込められたテーマが、ときには無償で配布されていたりします。

ありがたや、ありがたや!

テーマをえらぶ

いろいろとテーマをぐぐった中で、こちらのページが目に付きました。
hackerthemes.com

殆どがMITライセンスで公開されていて、サンプルも見やすくていいですね!

色々検討したのですが、ユーザー層を考えてみると。
Mopidyを使うのは恐らく、若い男性でラズパイとかが好きな技術系のひとたち。

なので、黒ベースで明暗がクッキリしたクールなイメージの、Neon Glowを導入することにしました。

読み込んでみる

こればっかりはnpmという訳にもいかないので、普通にダウンロードします。
貰ってきたzipを解凍してcssファイルを参照させます。

こちらがNeon Glow適用直前コミットのUI。
いわゆる「Bootstrap臭い」雰囲気です。
f:id:try_dot_net_core:20190813061933p:plain

そして、こちらがNeon Glow適用後コミットのものです。
オレンジがいい感じなので、ボタンもタイトルも全部bg-warning化しました。
f:id:try_dot_net_core:20190813062001p:plain

おお。
印象変わるわ~...。
うーん、やっぱ、デザイナってすげえな。

描画が、重い

見栄えが一気に音楽プレイヤーらしくなった、のは、大変いいのですが。
元々若干モッサリしていた描画が、なんとなくさらに、重くなった気がします。

読み込んだCSSのサイズを見てみると。

  • AdminLTE: 345KB(16,763行)
  • Neon-Glowテーマ: 258KB(9,232行)

なるほど。
こりゃレンダリングに負荷が掛かっててもおかしくないですね...。

使っていない定義をピックアップ

こちらの記事にあるChromeのCoverageツールで、CSS上の未使用ルールがどのくらいあるのか、見てみました。
qiita.com

Chromeの開発者ツールから、左のメニュー→「More tools」→「Coverage」でカバレッジツールが出てきます。
ツールを出してからリロードし、UIを一通り触っていくと、使っているルールが緑色に、使っていないルールは赤にハイライトされます。

どれどれ...? f:id:try_dot_net_core:20190813064802p:plain
うへえ。
ほとんど使われていません。

そして、AdminLTEもNeon-Glowテーマも、Bootstrap4-CSSの修正版なので。
定義の重複が山のようにあります。

淡々と、手作業で

使ってないルールを一発で削除するような、便利なツールがあると良いのですが。
今のところ、そういうツールは、私は知りません。

仕方がないので、Chromeのツール画面とVisual Studioを行き来しながら、CSSのルールを一つずつ削除していきました。

無心に小一時間ほど、淡々と作業した結果。

  • AdminLTE: 13KB(566行)
  • Neon-Glowテーマ: 37KB(1,662行)

まで、縮小することが出来ました...!

ロード早い!アニメーション軽い!

動画を撮るツールが手元に無いため、違いをお見せすることが出来ないのですが。
ブラウザロード時の初回描画も、アニメーションも、見るからに早くなりました!

「動いてるから、いいじゃない」と、ついサボりたくなるCSSのメンテですが。
体感には、明らかな差が出来るもんですね。

以上、デザイン適用とCSS軽量化のおはなし、でした!

音楽サーバ"Mopidy"のフロントエンドを作る:11 スワイプ操作を導入する

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第11回です。
完成版Mopidy.Finderのライブデモはこちら。

今回は、スワイプ操作を検知して画面を移動する実装を追います。

使えることは使える、けども...

前回、デバイスごとに見栄えを調整しました。

そしてスマートフォン表示時は、ヘッダバーにボタンを置いて、リストの切替が出来るようにしてあります。
f:id:try_dot_net_core:20190812131545p:plain

しかし、実際に操作してみると。
UIの切替が、思ったより不便でした...。

片手サイズのデバイスでヘッダをタッチすると、手でデバイス全体が隠れちゃうんです。

あんまり好きじゃないなー、ということで。
スワイプ操作によるUI切替を、導入することにしました。

Hammer.JSはスグレモノ

Hammer.jsは、ブラウザのタッチ操作をひと纏めに検出出来る、便利なライブラリです。
以前の案件で使っていたこともあり、特に迷いも無くこれを導入しました。
なんとなく、iOSジェスチャー検知を思わせる作りです。

Vue用にラップされたライブラリ、vue2-hammervue-touchもあるんですが。
特に、ラップしてもらう必要も感じませんで...。
そのまんま使っています。

インストールはいつものnpm経由で。

# npm install hammerjs

@typesに型定義がありますので、こちらも入れておきます。

# npm install -D @types/hammerjs

スワイプ検知イベントを書く

Hammer.jsでは、スワイプの検出を下のように書きます。

import * as Hammer from 'hammerjs';

const elem = document.querySelector('.detector-element');
const detector = new Hammer(elem);
detector.get('swipte').set({
    direction: Hammer.DIRECTION_HORIZONTAL // 水平方向のスワイプを検出する。
});
detector.on('swipeleft', () => {
    // 左スワイプ検知時の処理
});

これで、タッチパネルはおろか、マウス操作でも反応してくれる、スグレモノです。

Android-Chromeで反応しない...?

さてさて、Viewの各パーツにHammer.jsを導入して、動作を確認してみたところ。
Android機のChromeで、反応がありません。

ぐぐってみると、not worksのワードがずらり。
f:id:try_dot_net_core:20190812201459p:plain どうも、頻出する現象のようですね。

ご本家GitHubのissueで、解決策が議論されていました。
こちらのコメントで、コード例があります。

var hammertime = new Hammer.Manager(document.querySelector('.l-main'), {
    touchAction: 'auto',
    inputClass: Hammer.SUPPORT_POINTER_EVENTS 
        ? Hammer.PointerEventInput 
        : Hammer.TouchInput,
    recognizers: [
        [Hammer.Swipe, {
            direction: Hammer.DIRECTION_HORIZONTAL
        }]
    ]
});

ほうほう。イベントの入力方式を、対応状況に応じて切り替えてるのかな?
これをこのまま書いてみると、@types上の変数定義が無いため、ビルドが通りません。

この変数、ライブラリの実装にはあるんかいな?と調べてみると。
このへんに記述がありました

var SUPPORT_TOUCH = 'ontouchstart' in window;
var SUPPORT_POINTER_EVENTS = prefixed(window, 'PointerEvent') !== undefined;
var SUPPORT_ONLY_TOUCH = SUPPORT_TOUCH && MOBILE_REGEX.test(navigator.userAgent);

こりゃ単に、@types上の定義が不足してるだけ、ですね。
例によって型定義ファイルをtypesフォルダにコピーし、変数定義を追記します。
types/hammerjs/index.d.ts

interface HammerStatic
{
  // -- 追記部分のみ抜粋 --
  SUPPORT_TOUCH: boolean;
  SUPPORT_POINTER_EVENTS: boolean;
  SUPPORT_ONLY_TOUCH: boolean;
  ...

Android-Chromeで試すと、きちんと検知出来るようになっていました。

うむよしよし!、と思ってたら...。
この方法だと、PCのマウス操作に反応しなくなっちゃいまいした...。

うーん、PCブラウザのマウス操作なんで、別にオミットしちゃってもいいんですが。
気になるっちゃ、気になる。

悩んでいたら、例のissueの少し下の方で、CSSによる対応サンプルがありました。

touch-action: pan-y;

なるほど、スクロールの上下操作のみを許可するよ、と定義しちゃうわけですね。
試してみると、Hammer.js側の方はサンプル通りの記述のまま、Android-Chromeで動くようになりました!

教訓。
英語でも、きちんと全部読め、ということですね...。

アニメーションを導入する

検知イベントを取れるようになれば、あとはUIの切替機能を加えるだけです。

しかし、元々はタブパネルのように切り替えを実装していたもの。
左右にスワイプしたのに、UIが横移動せずにガチャン、と切り替わると妙な感じです。
ここは、横移動アニメーションを追加したいところ。

そこで、頼りになるCSSアニメーションライブラリAnimate.cssにご活躍いただきます。

なお、Animate.cssもnpmパッケージ化されており、インストールは至極簡単です。

# npm install animate.css

使い方も、至極簡単。
DOM要素のクラスに、animatedと、各アニメーションの種類を示すクラスを追加するだけです。

import 'animate.css/animate.css';

const elem =  document.querySelector('.anim-elem');
elem.classList.add('animated', 'fadeOut');

たったこれだけで、CSSによるアニメーションが動いてくれます。
なんとまあ、簡単だこと!

これをなるべく多用できるように、こんなユーティリティクラスを用意していました。
src/ts/Utils/Animate.ts:

export default class Animate {
    // -- 一部抜粋 --
    private _isHidingAnimation: boolean = false;
    private _resolver: (value: boolean) => void = null;
    private _elem: HTMLElement = null;
    private _classes: DOMTokenList = null;

    public constructor(elem: HTMLElement) {
        this._elem = elem;
        this._classes = this._elem.classList;

        this.OnAnimationEnd = this.OnAnimationEnd.bind(this);
    }

    public Execute(animation: Animation, speed: Speed = Speed.Normal): Promise<boolean> {
        return new Promise((resolve: (value: boolean) => void): void => {
            this._resolver = resolve;
            this._elem.addEventListener(Animate.AnimationEndEvent, this.OnAnimationEnd);

            // 同じ内容のアニメーションが既に設定済みか否か
            const needsDefer = (
                this._classes.contains(Animate.ClassAnimated)
                && this._classes.contains(animation.toString())
            );

            Animate.ClearAnimation(this._elem);

            (needsDefer)
                //既にアニメーションセット済みのとき: 一度クリアしたあとで遅延実行
                ? _.defer((): void => {
                    this.InnerExecute(animation, speed)
                })
                // プレーン状態のとき: 即時アニメーション実行
                : this.InnerExecute(animation, speed);
        });
    }
    
    private InnerExecute(animation: Animation, speed: Speed = Speed.Normal): void {
        this._isHidingAnimation = Animate.IsHideAnimation(animation);

        if (!this.GetIsVisible())
            this.ShowNow();

        this._classes.add(Animate.ClassAnimated);
        this._classes.add(animation.toString());
        if (speed !== Speed.Normal)
            this._classes.add(speed.toString());

        // animationendイベントタイムアウト: 100ms加算。
        let endTime = -1;
        switch (speed) {
            case Speed.Slower: endTime = 3100; break;
            case Speed.Slow: endTime = 2100; break;
            case Speed.Normal: endTime = 1100; break;
            case Speed.Fast: endTime = 900; break;
            case Speed.Faster: endTime = 600; break;
        }

        setTimeout((): void => {
            if (this._resolver)
                this.Resolve(false);
        }, endTime);
    }
    // -- 以降、終了処理など --
}

アニメーションの終了をawaitで待てるようにしてあります。
備えは万全!

横移動アニメーションをいれる...と?

プロジェクトも終盤になると、実装が整理されて抽象クラスが増えていきますね。

Mopidy.Finderでは、カラム表示/フルスクリーン表示を切り替えるコンテンツ部分を、ContentDetailBaseというクラスに機能集約していました。

そこに、左右にスライド移動するアニメーションメソッドを加えます。
src/ts/Views/Bases/ContentDetailBase.ts:

private animate: Animate;

// -- 中略 --

public async SlideInRight(): Promise<boolean> {
    this.ToVisible();
    await this.animate.Execute(this.AnimationSlideInRight, this.AnimationSpeed);
    this.animate.Clear();
    this.ToVisible();

    return true;
}

public async SlideInLeft(): Promise<boolean> {
    this.ToVisible();
    await this.animate.Execute(this.AnimationSlideInLeft, this.AnimationSpeed);
    this.animate.Clear();
    this.ToVisible();

    return true;
}

public async SlideOutRight(): Promise<boolean> {
    this.ToVisible();
    await this.animate.Execute(this.AnimationSlideOutRight, this.AnimationSpeed);
    this.animate.Clear();
    this.ToHide();

    return true;
}

public async SlideOutLeft(): Promise<boolean> {
    this.ToVisible();
    await this.animate.Execute(this.AnimationSlideOutLeft, this.AnimationSpeed);
    this.animate.Clear();
    this.ToHide();

    return true;
}

さて、スワイプ操作時に移動してみましょう!
f:id:try_dot_net_core:20190812215110p:plain
あれえ?
スライドアウトは動くんですが、移動先のUIがスライドインしてきません...。

UIのレイアウト修正

なぜこうなるか、というと。

Mopidy.Finderの画面構造では、Bootstrapのrowに複数のcol-lgを入れることで、各種モニタサイズに対応させていました。

スマートフォンサイズのディスプレイでは、下のような表示になっています。
f:id:try_dot_net_core:20190812221258p:plain
Freepik from www.flaticon.com is licensed by CC 3.0 BY

この図の「コンテンツ1」がスワイプ操作を受けて左にスライドアウトしているとき、「コンテンツ2」は画面に見えない下の方で、スライドインアニメーションしていた、というわけなんですね...。

なるほどこりゃイカン、ということで、スマートフォン表示時はposition: absoluteになるように、CSSを調整します。
src/css/site.css:

.position-static {
    position: static !important;
}
.position-absolute {
    position: absolute !important;
}
.position-relative {
    position: relative !important;
}

そしてこのCSSクラスを、モニタサイズに合わせてセットするメソッドを作ります。 src/ts/Views/Bases/ContentDetailBase.ts:

private static readonly PositionStatic: string = 'position-static';
private static readonly PositionAbsolute: string = 'position-absolute';

// -- 中略 --

public ToPositionStatic(): void {
    const classes = this.$el.classList;
    if (!classes.contains(ContentDetailBase.PositionStatic))
        classes.add(ContentDetailBase.PositionStatic);
    if (classes.contains(ContentDetailBase.PositionAbsolute))
        classes.remove(ContentDetailBase.PositionAbsolute);
}

public ToPositionAbsolute(): void {
    const classes = this.$el.classList;
    if (!classes.contains(ContentDetailBase.PositionAbsolute))
        classes.add(ContentDetailBase.PositionAbsolute);
    if (classes.contains(ContentDetailBase.PositionStatic))
        classes.remove(ContentDetailBase.PositionStatic);
}

それらをモニタサイズ検知時に実行するように組み込み。
いざ動作確認!
f:id:try_dot_net_core:20190812222759p:plain
ああ、よかった!
カレントUIがスライドアウトするのと一緒に、別のUIがスライドインしてきてます。

以上、スワイプ操作の導入のおはなし、でした!

音楽サーバ"Mopidy"のフロントエンドを作る:10 デバイス別の表示調整

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第10回です。
完成版Mopidy.Finderのライブデモはこちら。

今回はBootstrap4をベースにした、デバイスごとに表示を最適化するフローを追います。

どんなふうにする?

まず、PCブラウザサイズ。
私が最も使うはずのサイズです。
なるべく全機能を表示しておき、マウスで即座に各機能にアクセスしたいです。
f:id:try_dot_net_core:20190811095629p:plain
Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY

次に、タブレットサイズ。
それなりのモニタサイズがありますが、常時メニューを表示しておくには小さすぎます。
サイドバーは、ハンバーガーメニューの開閉式にしておきましょう。

そして、タブレットを横にしたときは、カラムが並ぶように。
縦にしたときは、各カラムがフルスクリーン表示されるようにしたいです。
f:id:try_dot_net_core:20190811101001p:plain Icons made by Dave Gandy www.flaticon.com is licensed by CC 3.0 BY

そして、スマートフォンサイズ。
モニタサイズは小さいため、縦横ともにフルスクリーンで表示しておきたいです。
f:id:try_dot_net_core:20190811095705p:plain Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY

モニタサイズを判定する

Bootstrapではマークアップ時に`lg'や'md'のようなサイズ閾値を織り交ぜることで、自動的にカラム表示/フルスクリーン表示を切り替えて貰えますよね。

しかし、「今現在、どのサイズ閾値を使っているのか」を取得する方法が、プレーンなBootstrapの機能の中では見つけることが出来ませんでした。

そこで、前回記事に挙げたResponsive Bootstrap Toolkitを導入して、モニタサイズがBootstrap上のどれに当たるのか、を取得するようにしました。

しかし前回記事で少し触れたように、Responsive Bootstrap ToolkitはまだBootstrap4には対応していません。

ぐぐってみると、ご本家GitHubのissueで対応方法が議論されていました。
github.com

ほうほう、useメソッドで閾値定義を渡せばよい、と。

ということで、このissueに記載があった定義を、そのままコピペで持ってきました。
src/ts/Libraries.ts:

Libraries.ResponsiveBootstrapToolkit.use('bs4', {
    'xs': Libraries.$('<div class="d-xs-block d-sm-none d-md-none d-lg-none d-xl-none"></div>'),
    'sm': Libraries.$('<div class="d-none d-sm-block d-md-none d-lg-none d-xl-none"></div>'),
    'md': Libraries.$('<div class="d-none d-md-block d-sm-none d-lg-none d-xl-none"></div>'),
    'lg': Libraries.$('<div class="d-none d-lg-block d-sm-none d-md-none d-xl-none"></div>'),
    'xl': Libraries.$('<div class="d-none d-xl-block d-sm-none d-md-none d-lg-none"></div>')
});

カラム/フルスクリーン切替の閾値をきめる

Bootstrap4のサイズは、公式ドキュメントでは下記のとおりです。

  • xs: 幅576px未満
  • sm: 幅576px以上 - 768px未満
  • md: 幅768px以上 - 992px未満
  • lg: 幅992px以上 - 1200px未満
  • xl: 幅1200px以上

当初はmdサイズを基準に切り替えていたのですが、これではタブレットを水平にしてもカラム表示になりませんでした。

htmlのmetaタグでviewportを指定しているため、デバイス本来の解像度よりも小さめに判定されるためです。

<meta name="viewport" content="width=device-width,initial-scale=1">

ということで、切替閾値lgに変更しました。

閾値に合わせた表示切替ロジック

Mopidy.Finderでは、ディスプレイサイズに合わせた表示を、NavigationControllerで制御するようにしました。
コンストラクタから呼び出すInitialNavigationメソッドの中で、表示制御メソッドを実行しています。
src/ts/Controllers/NavigationController.ts: L63

private async InitialNavigation(): Promise<boolean> {
    await this._store.TryConnect();
    const updateProgress = await this._store.GetDbUpdateProgress();

    // どうも、ResponsiveToolkitの初期化後から反応が正しくなるまで
    // すこし時間がかかるっぽい。
    // Settingsクエリが終わるまで待ってからAdjustする。
    this.AdjustScreen();
    // -- 中略 --
}

Responsive Bootstrap Toolkitは、各閾値ごとに定義した要素の表示状態を見ることで現在のサイズを取得しているようです。
なので、初回描画が終わるまで、API呼び出しを挟むことで少し待たせています。

そして、表示制御メソッドの中身はこんなかんじ。 src/ts/Controllers/NavigationController.ts: L95

private AdjustScreen(): void {
    // コンテンツは、mdサイズを基点にカラム<-->フルスクリーンを切り替える。
    if (this._viewport.is('<=md')) {
        this._content.ContentToFullscreen();
    } else {
        this._content.ContentToColumn();
    }

    // サイドバーは、lgサイズを基点に常時表示<-->操作終了で非表示化を切り替える。
    if (this._viewport.is('<=lg')) {
        this._headerBar.SetSideBarClose();
    } else {
        this._headerBar.SetSideBarOpen();
    }
    // -- 中略 --
}

this._viewportは、Responsive Bootstrap Toolkitインスタンスをセットしています。
isメソッドで比較演算子が使えるのは、便利ですね!

やれやれ一安心...と思いきや

閾値lgにセットして、手持ちのiPad mini4 と iPhone6+と で表示を確認しました。
まず、iPad mini4 f:id:try_dot_net_core:20190811112909p:plain よしよし、と。

次に、iPhone6+。
f:id:try_dot_net_core:20190811113517p:plain ボケボケでなんですが、これも表示は想定どおりです。

最後に、いま私が普段使いしている、MediaPad M5で試します。
f:id:try_dot_net_core:20190811114030p:plain
あかーん!
横にしたとき、カラム表示になっていません...。

MediaPadのディスプレイサイズをみてみる

MediaPad M5のChromeにデバッガツールをアタッチしてみると。
f:id:try_dot_net_core:20190811114821p:plain スクリーンサイズの幅は、「963.137px」と出ています。
なるほど、lgサイズの992pxより、小さいわけですね...。

ここで少し悩みました。
ある程度は汎用的に環境を作り込んでくれているであろうBootstrap様のサイズ規定に、はたして手を入れるべきか?

しかし。
このタブレットは、決して珍しい種類のものではありません。
他のAndroidタブレットでも、同じ現象が出るかもしれません。

なにより。
このアプリは、主に私が使うもの。
オレオレ環境に合わせて、何が悪い!

と、3秒くらい葛藤した上で、BootstrapのCSSに手を入れることに、決定しました。

閾値サイズに手を入れる

BootstrapのCSSは、それはそれは巨大な記述の塊です。
しかし、サイズを規定している箇所の記述は、だいたいこんな感じです。

@media (min-width: 768px) { ...

この、@mediaで始まっているもののうち、lgサイズに該当する 992px の値を変えてしまえば、終わりです。

992pxを示す@media (min-width: 992px)が、11箇所。
そして992px未満を示す@media (max-width: 991.98px)の記述が、2箇所ありました。

これをそれぞれ、下のように書き換えました。

  • @media (min-width: 992px)@media (min-width: 930px)
  • @media (max-width: 991.98px)@media (max-width: 929.98px)

すこしマージンを取り、閾値を930pxとしました。

そして、MediaPad M5で表示を見てみます。
f:id:try_dot_net_core:20190811121209p:plain

おっしゃ!
狙いどおりです。

iPad mini4でも、表示は変わっていませんでした。
f:id:try_dot_net_core:20190811122138p:plain

まあ、当然ちゃ当然です。

その他、iOS機と各種Android機で表示を試し、狙い通りかを確認します。
f:id:try_dot_net_core:20190811123921p:plain

うんうん、ちゃんと見えてますね。

以上、デバイスに合わせた表示調整のおはなし、でした!

追記

ちょうどこの記事をかいた8月11日、こんなエントリがはてブに挙がってました。
hashimotosan.hatenablog.jp なるほど、560px/960pxの二つ、か。
これはシンプルでいいですね。

音楽サーバ"Mopidy"のフロントエンドを作る:09 TypeScript用の型定義をつくる、その2

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第9回です。
完成版Mopidy.Finderのライブデモはこちら。

今回も引き続き、TypeScriptでライブラリを使う際の型定義について、です。

@typesにない、だと...

Mopidy.FinderではUIの画面サイズの判定に、BootstrapのためのユーティリティResponsive Bootstrap Toolkitを使っています。

それを@typeでsearchしてみると... f:id:try_dot_net_core:20190810161131p:plain

うへえ。見つかんねえ...
大正義@typesとはいえ、型定義が存在しないライブラリも、あるんですね。

ないなら書けばいいじゃない

そう、定義が無いなら、書けばいいのです。
ゴハンが無いならケーキを食えばいいのです。

めんどくせえよアントワネット!

あら、意外とそうでもありませんですのよ。オホホ。

使うとこだけ、書けばいい

この、Responsive Bootstrap Toolkit
欲しい機能は、

  • isメソッド: Windowサイズ比較用メソッド
  • changedコールバック: Windowサイズ変更時のコールバック
  • currentメソッド: 現在のWindowサイズを取得するメソッド

くらいなもんです。
加えて、このライブラリはまだBootstrap4に対応していないため、

  • useメソッド: Windowサイズ定義をセットするメソッド

も入れておきます。

別にDefinitelyTypedリポジトリにプルリクする訳でもなく、自分が使うだけのこと。
それなら、使うところだけを定義しておけばいいのです!

定義の種類

まず定義するに当たって、読み込むライブラリが何者なのかを調べます。
型定義にはおおむね、下の4種類が多く見受けられます。

  1. newして使う、クラスの定義
  2. 既に生成済みのインスタンスの定義
  3. 各種クラスやインタフェース類、staticメソッドを集めた、名前空間としての定義
  4. staticなメソッドを持ち、かつnewでインスタンスを生成するクラスでもある定義。

このうち1.のクラス定義は、クラスベースのTypeScriptでは最も馴染みやすいものです。
declare class [クラス名]としておき、中身はインタフェースを書く要領でpublicメンバーを書き連ねていきます。

4.の複合型定義は、見かける頻度は高いんですが、書くのはしんどいです。
クラスと名前空間を同じ名前で定義し、staticメソッドやインタフェース定義などを名前空間に集めておく書き方です。
前回記事に挙げたSortableJSの定義が、このように作られています。

そして、冒頭で挙げたResponsive Bootstrap Toolkitは、どんなものでしょう?
ライブラリの使い方としては、下記のように書いてます。

import * as ResponsiveBootstrapToolkit from 'responsive-toolkit/dist/bootstrap-toolkit';

if (ResponsiveBootstrapToolkit.is('<=md')) {
    // md以下のときの処理
}

これは、2.のインスタンスをimportしている、と考えてよいでしょう。

インスタンスの型定義

型定義ファイルで書くdeclare構文。
これは、そのライブラリをimportしたときに存在するはずのもの、を示しています。

クラスが存在するはずならば、declare class [クラス名]となります。
Responsive Bootstrap Toolkitの場合はクラスでなくインスタンスになるため、存在するのは生成済みインスタンスの入れ物になる、変数名です。

この場合は、const/varで宣言された変数をdeclareします。

declare const ResponsiveBootstrapToolkit;

そして、そのインスタンスが持つべきメンバーを、インタフェースとして定義します。

interface IResponsiveBootstrapToolkit {
    is(complareString: string): boolean;
    changed(callback: () => void, waitMsec?: number): void;
    current(): string;
    use(framewrokName: string, breadpoints: { [breadkpoint: string]: JQuery }): void;
}

declareしたインスタンスは上述のインタフェースを持つため、型を指定してやります。

declare const ResponsiveBootstrapToolkit: IResponsiveBootstrapToolkit;

最後に、このライブラリはモジュールのため、存在宣言した変数をexportしてやります。

export = ResponsiveBootstrapToolkit;

全部合わせて、8行のコードで済みました!
types/responsive-toolkit/index.d.ts:

interface IResponsiveBootstrapToolkit {
    is(complareString: string): boolean;
    changed(callback: () => void, waitMsec?: number): void;
    current(): string;
    use(framewrokName: string, breadpoints: { [breadkpoint: string]: JQuery }): void;
}
declare const ResponsiveBootstrapToolkit: IResponsiveBootstrapToolkit;
export = ResponsiveBootstrapToolkit;

これで、Responsive Bootstrap Toolkitが、型安全に使えるようになります!

名前空間、と解釈してみる

このResponsive Bootstrap Toolkit生成済みインスタンスとして扱っていますが。
これは、staticなメソッドを持つ名前空間、として見ることも出来ます。

そのように、書き換えてみたのがこちら。

declare namespace ResponsiveBootstrapToolkit {
    function is(complareString: string): boolean;
    function changed(callback: () => void, waitMsec?: number): void;
    function current(): string;
    function use(framewrokName: string, breadpoints: { [breadkpoint: string]: JQuery }): void;
}
export = ResponsiveBootstrapToolkit;

これでも、ビルドは通ります。

ただ、名前空間として考えるには、少し機能が小さすぎますかね?
そのへんは、お好みで。

以上、TypeScriptの型定義づくり、その2でした!

音楽サーバ"Mopidy"のフロントエンドを作る:08 TypeScript用の型定義をつくる

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第8回です。
完成版Mopidy.Finderのライブデモはこちら。

今回は、TypeScriptでライブラリを使う際の、型定義について、です。

大正義@types

前回記事でちらっと触れましたが、最近はライブラリ配布時にTypeScript用の型定義も、同梱していることが散見されるようになりました。

しかし、たとえ同梱の定義が無いとしても。
有名なライブラリを網羅する、DefinitelyTypedというリポジトリ があります。

そして、それをnpmパッケージとして取り込んだものが、@typesです。

もはや、自分で型定義を書くことなんて無いのです!

...そう。
無いのです。定義済みの型定義で用事が済むのであれば...。

プロパティが足りない!

Mopidy.Finderでは、プレイリスト編集機能のためにSortableJSを導入しました。
ドラッグ&ドロップで要素を並べ替えできる、とても便利なライブラリです。
ここで、その@types上の定義を見てみましょう。

npmパッケージ名は、@types/sortablejsです。
インストールして、中身を除いてみます。

# npm install -D @types/sortablejs

設定値をセットするための、Options定義がこちら。
node_modules/@types/sortablejs/index.d.ts:

declare namespace Sortable {
    export interface Options {
        /**
         * ms, animation speed moving items when sorting, `0` — without animation
         */
        animation?: number;
        /**
         * Class name for the chosen item
         */
        chosenClass?: string;
        dataIdAttr?: string;
        /**
         * time in milliseconds to define when the sorting should start
         */
        delay?: number;
        /**
         * Disables the sortable if set to true.
         */
        disabled?: boolean;
        /**
         * Class name for the dragging item
         */
        dragClass?: string;
        /**
         * Specifies which items inside the element should be draggable
         */
        draggable?: string;
        dragoverBubble?: boolean;
        dropBubble?: boolean;
        /**
         * Class name for the cloned DOM Element when using forceFallback
         */
        fallbackClass?: string;
        /**
         * Appends the cloned DOM Element into the Document's Body
         */
        fallbackOnBody?: boolean;
        /**
         * Specify in pixels how far the mouse should move before it's considered as a drag.
         */
        fallbackTolerance?: number;
        fallbackOffset?: { x: number, y: number };
        /**
         * Selectors that do not lead to dragging (String or Function)
         */
        filter?: string | ((this: Sortable, event: Event | TouchEvent, target: HTMLElement, sortable: Sortable) => boolean);
        /**
         * ignore the HTML5 DnD behaviour and force the fallback to kick in
         */
        forceFallback?: boolean;
        /**
         * Class name for the drop placeholder
         */
        ghostClass?: string;
        /**
         * To drag elements from one list into another, both lists must have the same group value.
         * You can also define whether lists can give away, give and keep a copy (clone), and receive elements.
         */
        group?: string | GroupOptions;
        /**
         * Drag handle selector within list items
         */
        handle?: string;
        ignore?: string;
        /**
         * Call `event.preventDefault()` when triggered `filter`
         */
        preventOnFilter?: boolean;
        scroll?: boolean;
        /**
         * if you have custom scrollbar scrollFn may be used for autoscrolling
         */
        scrollFn?: ((this: Sortable, offsetX: number, offsetY: number, event: MouseEvent) => void);
        /**
         * px, how near the mouse must be to an edge to start scrolling.
         */
        scrollSensitivity?: number;
        /**
         * px
         */
        scrollSpeed?: number;
        /**
         * sorting inside list
         */
        sort?: boolean;
        store?: {
            get: (sortable: Sortable) => string[];
            set: (sortable: Sortable) => void;
        };
        setData?: (dataTransfer: DataTransfer, draggedElement: HTMLElement) => void;
        /**
         * Element dragging started
         */
        onStart?: (event: SortableEvent) => void;
        /**
         * Element dragging ended
         */
        onEnd?: (event: SortableEvent) => void;
        /**
         * Element is dropped into the list from another list
         */
        onAdd?: (event: SortableEvent) => void;
        /**
         * Created a clone of an element
         */
        onClone?: (event: SortableEvent) => void;
        /**
         * Element is chosen
         */
        onChoose?: (event: SortableEvent) => void;
        /**
         * Element is unchosen
         */
        onUnchoose?: (event: SortableEvent) => void;
        /**
         * Changed sorting within list
         */
        onUpdate?: (event: SortableEvent) => void;
        /**
         * Called by any change to the list (add / update / remove)
         */
        onSort?: (event: SortableEvent) => void;
        /**
         * Element is removed from the list into another list
         */
        onRemove?: (event: SortableEvent) => void;
        /**
         * Attempt to drag a filtered element
         */
        onFilter?: (event: SortableEvent) => void;
        /**
         * Event when you move an item in the list or between lists
         */
        onMove?: (event: MoveEvent) => boolean;
    }
}

プレイリストの編集時は、複数のトラックを選択して並べ替えをしたい。
なので、MultiDragプラグインを使いたい。

こちらの公式サンプルコードでは、このように書かれています。

new Sortable(multiDragDemo, {
    multiDrag: true, // Enable multi-drag
    selectedClass: 'selected', // The class applied to the selected items
    animation: 150
});

しかし、上述のオプション定義には、multiDrag項目が見当たらないのです...。
このままTypeScriptでサンプルのように書いてしまうと、ビルドエラーになります。

こんな場合は、慌てず騒がず。
型定義ファイルに、手を入れればいいのです!

型定義ファイルを配置する

node_modules下のファイルに直接手を入れるのは、ご法度です。
他の環境ではビルド出来なくなってしまいますね。

ではどうするか、というと。
プロジェクトに型定義ファイル用のフォルダを作り、型定義ファイルをコピーします。

Mopidy.Finderのプロジェクトでは、型定義配置用のフォルダtypesを作り、そこにライブラリ別に配置しています。
SortableJSの型定義は、types/sortable/index.d.tsに配置しました。

そして、元になった@typesのパッケージをアンインストールします。

# npm uninstall -D @types/sortablejs

その上で、TypeScriptに、コピーした型定義ファイルを参照させるように設定します。 tsconfig.jsoncompilerOptionsの中で、baseUrlpathsを書きます。

"compilerOptions": {
    // -- 中略 --
    "baseUrl": "./",
    "paths": {
        "sortablejs/modular/sortable.complete.esm": [ "types/sortable" ]
    }
}

pathsを使用する際は、そのパスの基準となるbaseUrlが必要になるようです。
ここでは、baseUrlはプロジェクトルートを示す./(=tsconfig.jsonがあるフォルダ)としています。

実際にSortableJSを使うTypeScriptモジュールでは、下記のようにimportして使います。

import Sortable from 'sortablejs/modular/sortable.complete.esm';

const elem = document.querySelector('.sortableWrapper');
Sortable.create(elem , {
    // 各種オプション定義
});

importするモジュールを、単にsortablejsでなくsortablejs/modular/sortable.complete.esm としてあるのは、SortableJSの全プラグインが同梱されたsortable.complete.esm.jsを使うためです。

公式ページのこちらに記載がありますね。

Visual Studioでは、型定義の参照先を変えた際、認識されない場合があります。※
※そのときは、Visual Studioを再起動してみてください。※

型定義ファイルに手を入れる

さて、型定義を好き放題にいじるための準備が整いました!
そこで、公式ドキュメントに書いてある、MultiDragプラグインの定義を読んでみます。

まず、Option

  • multiDrag
  • selectedClass
  • multiDragKey
  • onSelect
  • onDeselect

の5つの定義が増えたようです。

multiDragは、複数選択可否の設定です。
これをtrueとするとで、複数選択可能な状態にできる、と。

selectedClass は、選択したDom要素に付与するクラス名ですね。
選択状態にした際にCSSによって背景色を変えたいので、これは使えそうです。

multiDragKeyは、複数選択時にキーボードで押すキーのようです。
常時複数選択にするつもりなので、これは使いませんでした。

また、onSelectonDeselectのイベントハンドル2つ。
これは選択時と選択解除時に発火するイベントのようですね。
それぞれの時点でDomを操作するときに使えるかも。
でも、クラス名を自動で付与してくれるなら、使わないかな?

というわけで、Optionの定義に、multiDragselectedClassを追加しました。

declare namespace Sortable {
    export interface Options {
        // -- 中略 --
        multiDrag?: boolean;
        selectedClass?: string;
    }
}

そして、イベントオブジェクトのほうにも、値が増えています。

  • items
  • clones
  • oldIndicies
  • newIndicies

itemsはDom要素の配列が入るようですね。
cloneは...なんだろう?分からないまま、使わずじまいでした。

そして、oldIndiciesnewIndicies。これは、Dom要素ごとの順序インデックスを、新旧で持っているようです。
イベント定義記述のすぐ下に、Indiciesの定義が書いてありますね。

それらを、Eventの定義に追記していきます。

declare namespace Sortable {
    export interface SortableEvent extends Event {
        // -- 中略 --
        items: HTMLElement[];
        newIndicies: Index[] | undefined;
        oldIndicies: Index[] | undefined;
    }
    
    export interface Index {
        index: number;
        multiDragElement: HTMLElement
    }
}

なお、ドキュメント上のIndexの定義ではHTMLElementはelement、と書いてます。
しかし実際にイベントをダンプしてみると、名前はmultiDragElementのようです。
f:id:try_dot_net_core:20190809161655p:plain
こういうのは、実装に合わせて修正していきます。

修正後の型定義でビルドしてみる

型定義の修正が終わったら、実際にコードを書いて確かめます。
SortableJSの生成箇所は、こんなコードになっています。
src/ts/Views/Playlists/Lists/Tracks/TrackList.ts:

private async SetSortable(): Promise<boolean> {
    this.DisposeSortable();
    await Delay.Wait(10);

    this.sortable = Sortable.create(this.TrackListUl, {
        animation: 500,
        multiDrag: true,
        selectedClass: 'selected',
        dataIdAttr: 'data-uri',
        onEnd: (): void => {
            this.OnOrderChanged();
        }
    });

    return true;
}

TypeScriptをビルドしてみると... f:id:try_dot_net_core:20190809162836p:plain
はい、きちんと通ります!

プレイリスト画面で、実際に並べ替えを試してみます。
複数を選択して... f:id:try_dot_net_core:20190809163451p:plain

ドラッグすると... f:id:try_dot_net_core:20190809163519p:plain

順番が、変わりました! f:id:try_dot_net_core:20190809163544p:plain

以上、TypeScript型定義のメンテナンスのおはなし、でした!