はじめに
日々,PCを操作していると避けられないのがウインドウ切り替え. ショートカットを使えば比較的手軽にできる操作であるものの,ウインドウが多いと行き過ぎたりする. とはいえ,マウスでウインドウ選択はちょっと面倒…
そこで,タブレットに現在のウインドウ情報を反映,操作するアプリを制作した.
タブレットで表示されるウインドウを選択すると,そのウインドウがPCで選択される.
進捗
— やまもと (@NB29979) 2018年9月1日
- アイコンが表示できるように
- スクロール機能の実装
- リアルタイム通信ができるように
以前までの単純なSocketでつなぎ直すというヤバさ
現在ではJavaとC#でWebSoketを使っている() pic.twitter.com/yOU3qcuKIT
マウスの操作機能も付与した. キーボードを何らかの形で操作できるようになると,手元のタブレットだけでPC操作ができる. 今回はできる限りソースコードを取り上げていく.
コントローラ(タブレット): github.com
ホスト(PC): github.com
リポジトリ名がガバガバである
使用言語はそれぞれAndroid JavaとC#.
全体の構成
実装に入る前に構築したシステムの全体の構成についてとり上げておく.
- タブレットでは左側にPCのウインドウ一覧が,右側にマウスカーソル操作用のパッドがある.
- PCから各ウインドウのタイトルとアイコンのリストがjsonでタブレットに送られる.
- タブレットでウインドウを選んだ場合,ウインドウのタイトルがPCに送られる.
- タブレットでマウス操作(L/Rクリックやスクロール操作,カーソル移動)が入力されるとその操作がjsonでPCに送られる.
実装
本記事はコントローラ(タブレット)側.
WebSocket
今回のようなPC状のウインドウ状況の取得とマウスカーソルの操作では通信にリアルタイム性が求められるため,WebSocketを用いた通信を試みる. Android JavaでWebSocketを使う場合,以下のTooTallNate氏のライブラリが参考になる.
このライブラリを使用するためには,build.gradleに追記が必要.
build.gradle:
dependencies { implementation 'org.java-websocket:Java-WebSocket:1.3.8' }
このライブラリを使用すると,AsyncTaskの記述ナシに通信ができる. 実装箇所はMainActivityだけ.神か.
必要最小限のメソッドのオーバーライドで使用できるようになるのも,簡単に使えて◎.
MainActivity:
private class SendDataWebSocket extends WebSocketClient { public SendDataWebSocket(URI _uri){ super(_uri); } @Override public void onOpen(ServerHandshake _handShake){ System.out.println("Connected"); } @Override public void onMessage(final String _message){ runOnUiThread(new Runnable() { @Override public void run() { Type listType_ = new TypeToken<ArrayList<WindowRowData>>(){}.getType(); ArrayList<WindowRowData> windowRowDataList_ = new Gson().fromJson(_message, listType_); setLsvWindowList(windowRowDataList_); } }); } @Override public void onClose(int _code, String _reason, boolean _remote){ System.out.println("Disconnected"); } @Override public void onError(Exception e){ System.out.println(e); } }
jsonのパーサはGsonを使う(これにもbuild.gradleに記述が必要).
メッセージが到達した段階(onMessage())で,ビューにウインドウ一覧を表示するため,やむなくスレッドを立ち上げる.受け取ったjsonからウインドウのクラスをもつArrayListにマッピングする.
ウインドウ操作
ウインドウタイトルは左側にリストアップされる.
RecycleView
ウインドウはRecycleViewを使い描画される. Adapterのインスタンス生成時には,ウインドウタイトルにOnClickListenerをセットしておく.
MainActivity:
// ウインドウタイトルを選択した場合 windowRecycleViewAdapter = new WindowRecycleViewAdapter(this, windowList){ @Override public WindowViewHolder onCreateViewHolder(ViewGroup _parent, int _viewType) { final WindowViewHolder holder_ = super.onCreateViewHolder(_parent, _viewType); holder_.itemView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { final int pos_ = holder_.getAdapterPosition(); Send(new SendingData("SelectTitle", windowList.get(pos_).getTitle())); } }); return holder_; } };
何故かキャメルケースになっていないが,Send()メソッドでWebSocketを通じてPCに選択したウインドウのタイトルを送る.
PCで更新があった場合のウインドウリスト表示は以下のようにArrayListの更新後,adapterに更新をnotifyすることで行う.
MainActivity:
public void setLsvWindowList(ArrayList<WindowRowData> _newWindowList){ ArrayList<WindowRowData> windowList_ = new ArrayList<>(); windowList_.addAll(windowList); // 閉じられたウインドウをリストから削除 for (WindowRowData w : windowList_) { if (!_newWindowList.contains(w)) { removeWindow(w); } } // 過去のウインドウリストにない新規ウインドウを追加 for(WindowRowData w : _newWindowList){ if(!windowList_.contains(w)){ addWindow(w); } } } public void addWindow(WindowRowData _windowData){ windowList.add(_windowData); windowRecycleViewAdapter.notifyItemChanged(windowList.size()-1); } public void removeWindow(WindowRowData _windowData){ int index_ = windowList.indexOf(_windowData); Iterator<WindowRowData> it = windowList.iterator(); while(it.hasNext()){ WindowRowData rowData_ = it.next(); if(rowData_.getWindowData().equals(_windowData)){ it.remove(); windowRecycleViewAdapter.notifyItemRemoved(index_); windowRecycleViewAdapter.notifyItemRangeChanged(index_, windowList.size()); break; } } }
ArrayListのremoveはiteratorを使用しないとjavaのremoveの処理の関係上エラーが生じることがあるので注意する. 泥臭いけれどエラーが出てしまう以上仕方がない.
画像の受信
画像はPCでBase64にエンコードし,タブレットでデコードする. AdapterのonBindViewHolderでholderにBindするViewの諸設定を行うが,ここでデコードを行う.
WindowRecycleViewHolder:
@Override public void onBindViewHolder(WindowViewHolder _holder, int _pos){ WindowRowData data_ = windowList.get(_pos); _holder.titleView.setText(data_.getTitle()); if(!data_.getIcon().equals("null")){ byte[] decodedByteArray_ = Base64.decode(data_.getIcon(), 0); Bitmap receivedBmp_ = BitmapFactory.decodeByteArray(decodedByteArray_, 0, decodedByteArray_.length); Bitmap resourceBmp_ = BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher); receivedBmp_ = Bitmap.createScaledBitmap(receivedBmp_, resourceBmp_.getWidth(), resourceBmp_.getHeight(), true); _holder.iconView.setImageBitmap(receivedBmp_); } }
タブレット上でデコードのコストがかかってしまうが,画像を直接送信する通信コストよりかはマシ.
マウス操作
右側の灰色の領域でマウス操作が可能になっている.
L/Rクリック
マウスのL/Rクリック動作はタブレットの音量ボタンを押下することで可能にした.
これでマウスパッド部分はカーソル移動・スクロール操作のみを担当することになり,操作性がある程度上がる?
MainActivity:
@Override public boolean dispatchKeyEvent(KeyEvent _keyEvent) { switch (_keyEvent.getKeyCode()) { case KeyEvent.KEYCODE_VOLUME_UP: if (_keyEvent.getAction() == KeyEvent.ACTION_DOWN) { if(!isRClickDOWN) { Send(new SendingData("MouseEvent", "LClickDOWN")); isRClickDOWN = true; } } else{ isRClickDOWN = false; Send(new SendingData("MouseEvent", "LClickUP")); } break; case KeyEvent.KEYCODE_VOLUME_DOWN: if (_keyEvent.getAction() == KeyEvent.ACTION_DOWN) { if(!isLClickDOWN) { Send(new SendingData("MouseEvent", "RClickDOWN")); isLClickDOWN = true; } } else{ isLClickDOWN = false; Send(new SendingData("MouseEvent", "RClickUP")); } break; } return true; }
dispatchKeyEvent()はAndroid端末の電源キーや音量ボタンの役割をオーバーライド可能なメソッド.
このアプリケーションでは音量ボタンの挙動を上書きするので,KEYCODE_VOLUME_UP,KEYCODE_VOLUME_DOWNについて記述を行う.
カーソル移動・スクロール操作
カーソル移動・スクロール操作はWebSocketを使う主な要因.これらの操作はマウスパッドであるImageViewで実装する.
CursorImageView:
@Override public boolean onTouchEvent(MotionEvent _event){ pos currentPos = new pos(_event.getX(), _event.getY()); if(_event.getAction() == MotionEvent.ACTION_DOWN || _event.getAction() == MotionEvent.ACTION_POINTER_DOWN){ maxPointerCount = 0; oldPos.setPos(_event.getX(), _event.getY()); } maxPointerCount = Math.max(_event.getPointerCount(), maxPointerCount); pos variation = new pos(currentPos.x - oldPos.x, currentPos.y - oldPos.y); switch (maxPointerCount){ case 1: mainActivity.Send(new SendingData("MouseEvent", "MoveCursor", (double) variation.x, (double) variation.y, maxPointerCount)); break; case 2: mainActivity.Send(new SendingData("MouseEvent", "ScrollWindow", (double) variation.x, (double) variation.y, maxPointerCount)); break; case 3: mainActivity.Send(new SendingData("MouseEvent", "HScrollWindow", (double) variation.x, (double) variation.y, maxPointerCount)); break; } oldPos.setPos(currentPos.x, currentPos.y); return true; }
ユーザのタップによって発火するonTouchEvent()に処理を書いていく.
カーソル操作やスクロール操作は指の本数で区別している.これは現代のトラックパッドでよくみられるやつ.
まとめ
案外,自分が描いたコードを抜粋,抜粋して説明することの難しさを感じている. 最終的に書き上げたコードは最初に上げたGitHubに上がっているので,UI部品の細かな実装やクラス/メソッドの詳細はそちらを参考にしてほしい.