2015/12/28

browserifyでknockout.jsをトランスパイル

knockout.jsES2015 で書くための手順についてまとめる。

環境

  • OS: Microsoft Windows [Version 6.1.7601]
  • Node: v4.2.3
  • npm: 2.14.7


モジュール バージョン

各モジュールのバージョンは以下のとおり。

"devDependencies": {
  "babel-preset-es2015": "^6.3.13",
  "babelify": "^7.2.0",
  "browserify": "^12.0.1",
  "knockout": "^3.4.0"
}


プロジェクトフォルダの作成

mkdir knockout-es6-project
cd knockout-es6-project
mkdir src
mkdir dist
  • src フォルダに変換前のソースコードを作成する
    • src/index.js がメインファイルであると想定する
  • dist フォルダにトランスパイルされた jsファイルが保存される
    • dist/bundle.js という名前で生成


package.json の作成

npm init
# 適当に入力


browserify のインストール

npm install --save-dev browserify

browserify は クライアントサイドのJavaScriptでモジュールシステムを実現するツール。



babelify のインストール

npm install --save-dev babelify babel-preset-es2015

babelifybrowserify が javascript を処理する際に babel によって
ES2015で書かれたソースを (browserが理解できる) ES5 に変換するツール。

babel-preset-es2015 は ES2015 の変換機能。

babel 本体から変換機能を切り離すことで、利用者は使用する機能を 取捨選択することが可能になっている。

browserify と組み合わせず、babel のコマンドラインツールで 変換することも可能。
今回は、後述する npm run build コマンド一回で トランスパイルが完了するようにしたかったため、このような構成とした。



knockoutのインストール

npm install --save-dev knockout

browserify によってトランスパイルした際に dist/bundle.jsknockout.js 本体も含まれるようにする。



package.json の修正

{
  "name": "knockout-es6-project",
  "version": "1.0.0",
  "description": "knockout es6 sample",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "browserify src/index.js -o dist/bundle.js"
  },
  "keywords": [
    "knockout.js",
    "babel",
    "es2015",
    "browserify"
  ],
  "author": "Kazunori Kimura <kazunori.kimura.js@gmail.com>",
  "license": "MIT",
  "devDependencies": {
    "babel-preset-es2015": "^6.3.13",
    "babelify": "^7.2.0",
    "browserify": "^12.0.1",
    "knockout": "^3.4.0"
  },
  "browserify": {
    "transform": [
      [
        "babelify",
        {
          "presets": [
            "es2015"
          ]
        }
      ]
    ]
  }
}

修正点は以下の2つ。

  • scriptsbuild の指定を追加。
    • browserify を使用して index.jsbundle.js に変換する
  • browserify を追加し、presets を指定
    • .babelrc というファイルを作成して presets の指定を行うことも可能だが、package.json に集約するほうがシンプルであると判断した


サンプルコード

src/index.js

import ko from "knockout";
import MyViewModel from "./MyViewModel";

ko.components.register("MyComponent",
{
  viewModel: MyViewModel,
  template: `<div>
  <input type="text" data-bind="value: text, valueUpdate: 'afterkeydown'">
  length: <span data-bind="text: text().length"></span>
</div>`
});

ko.applyBindings();

src/MyViewModel.js

import ko from "knockout";

export default class MyViewModel {
  constructor(){
    this.text = ko.observable("");
  }
}

dist/index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <div data-bind="component: 'MyComponent'"></div>
  <script src="bundle.js"></script>
</body>
</html>


トランスパイルの実行

$ npm run build

> knockout-es6-project@1.0.0 build C:\Repository\knockout-es6-project
> browserify src/index.js -o dist/bundle.js

dist/bundle.js が生成されていることを確認する。



参考

Smarter knockout applications with ES6/7

2015/12/17

browserifyでReact(JSX)をトランスパイル

概要

  • React (JSX) と ES2015 をトランスパイルする環境を構築する
  • できるだけシンプルな構成を目指す



環境

  • OS: Microsoft Windows [Version 6.1.7601]
  • Node: v4.2.3
  • npm: 2.14.7



モジュール バージョン

各モジュールのバージョンは以下のとおり。

"devDependencies": {
  "babel-preset-es2015": "^6.3.13",
  "babel-preset-react": "^6.3.13",
  "babelify": "^7.2.0",
  "browserify": "^12.0.1",
  "react": "^0.14.3",
  "react-dom": "^0.14.3"
}

バージョンによって手順が変わる可能性が高いため 最新の情報を参照する必要がある。



プロジェクトフォルダの作成

mkdir react-babel-sample
cd react-babel-sample
mkdir src
mkdir dist
  • src フォルダに変換前のソースコードを作成する
    • src/index.js がメインファイルであると想定する
  • dist フォルダにトランスパイルされた jsファイルが保存される
    • dist/bundle.js という名前で生成



package.json の作成

npm init
# 適当に入力



browserify のインストール

npm install --save-dev browserify

browserify は クライアントサイドのJavaScriptでモジュールシステムを実現するツール。



babelify のインストール

npm install --save-dev babelify babel-preset-es2015 babel-preset-react

babelifybrowserify が javascript を処理する際に babel によって ES2015やReact(JSX)で書かれたソースを (browserが理解できる) ES5 に変換するツール。

babel-preset-es2015, babel-preset-react は それぞれ ES2015, React(JSX) の変換機能。

babel 本体から変換機能を切り離すことで、利用者は使用する機能を 取捨選択することが可能になっている。

browserify と組み合わせず、babel のコマンドラインツールで 変換することも可能。
今回は、後述する npm run build コマンド一回で トランスパイルが完了するようにしたかったため、このような構成とした。



Reactのインストール

npm install --save-dev react react-dom

browserify によってトランスパイルした際に dist/bundle.jsReact.js 本体も含まれるようになる。



package.json の修正

{
  "name": "react-babel-sample",
  "version": "1.0.0",
  "description": "react project sample",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "browserify src/index.js -o dist/bundle.js"
  },
  "keywords": [
    "react",
    "babel",
    "browserify"
  ],
  "author": "Kazunori Kimura <kazunori.kimura.js@gmail.com>",
  "license": "MIT",
  "devDependencies": {
    "babel-preset-es2015": "^6.3.13",
    "babel-preset-react": "^6.3.13",
    "babelify": "^7.2.0",
    "browserify": "^12.0.1",
    "react": "^0.14.3",
    "react-dom": "^0.14.3"
  },
  "browserify": {
    "transform": [
      [
        "babelify",
        {
          "presets": [
            "es2015",
            "react"
          ]
        }
      ]
    ]
  }
}

修正点は以下の2つ。

  • scriptsbuild の指定を追加。
    • browserify を使用して index.jsbundle.js に変換する
  • browserify を追加し、presets を指定
    • .babelrc というファイルを作成して presets の指定を行うことも可能だが、package.json に集約するほうがシンプル



サンプルコード

src/index.js

// index.js
import React from "react";
import ReactDOM from "react-dom";
import MainComponent from "./MainComponent";

ReactDOM.render(
  <MainComponent />,
  document.getElementById("content")
);

src/MainComponent.js

import React from "react";
import MySubComponent from "./SubComponent";

export default class MainComponent extends React.Component {
  render(){
    return(
      <div className="main-component">
        <h1>Main</h1>
        <MySubComponent />
      </div>
    );
  }
}

src/SubComponent.js

import React from "react";

export default class MySubComponent extends React.Component {
  render() {
    return(
      <div className="sub-component">
        sub component.
      </div>
    );
  }
}

dist/index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <div id="content"></div>

  <script src="bundle.js"></script>
</body>
</html>



トランスパイルの実行

$ npm run build

> react-babel-sample@1.0.0 build C:\Repository\react-babel-sample
> browserify src/index.js -o dist/bundle.js

dist/bundle.js が生成されていることを確認する。




更新履歴

  • 2015-12-15: 新規作成
  • 2015-12-16: サンプルコード追加

2015/11/13

koa と socket.io を使用したチャットアプリ

koa と socket.io を使用したチャットアプリを作成する。

koasocket.io を組み込んだ、koa.io を使用する。

(koa.io は現在開発中だが、とりあえず動作に支障はなかった。
ただ、 socket.io をそのまま組み込んでもコード量はそれほど変わらないと思われる。)



koa.ioのインストール

koa.iokoa を含んでいるので、先に koa をアンインストールしてからインストールする。

$ npm uninstall --save koa
$ npm install --save koa.io



サーバー側の処理

app.js

// app.js
var app = require('koa.io')();
var route = require('koa-route');
var serve = require('koa-static');
var views = require('koa-views');
var moment = require('moment');

// ectをテンプレートエンジンとして指定
app.use(views(__dirname + '/views', {
  map: {
    html: 'ect'
  }
}));

// -- routing --
// GET /chat
app.use(route.get('/chat', function *chat(){
  yield this.render('chat.ect', {
    title: 'CHAT',
    version: '1.0.0'
  });
}));

// static files
app.use(serve(__dirname + '/public'));

// -- WebSocket --

app.io.use(function* (next){
  console.log('[connect]');
  yield* next;
  console.log('[disconnect]');
  if (this.username) {
    this.broadcast.emit('message', { message: 'logout: ' + this.username});
    this.username = '';
  }
});

app.io.route('login', function*(next, args){
  console.log('[login] ' + JSON.stringify(args));
  this.username = args.username;
  var msg = { message: 'login: ' + this.username };
  this.broadcast.emit('message', msg);
  this.emit('message', msg);
});

app.io.route('logout', function*(next){
  console.log('[logout] ' + this.username);
  if (this.username) {
    this.broadcast.emit('message', { message: 'logout: ' + this.username});
    this.username = '';
  }
});

app.io.route('message', function*(next, args){
  console.log('[message] ' + JSON.stringify(args));
  var msg = {
    date: moment().format('hh:mm:ss'),
    username: this.username,
    message: args.message
  };
  this.broadcast.emit('message', msg);
  this.emit('message', msg);
});

app.listen(3000);

app.io の箇所が koa.iosocket.io をラッピングしている部分。
非常に簡単に WebSocket のサーバーサイド処理が書ける。



クライアント側の処理

今回はViewを knockoutjs で作成する。

(ちなみに… knockoutjs の Model や ViewModel を ES2015 で書けないか試行錯誤してみたが、肝心の ko.observable の変数が上手く認識できず、断念。)

html (ECT)

views/layout.ect

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title><%- @title %></title>
  <link rel="stylesheet" href="/honoka/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/style.css">
  <!--[if lt IE 9]>
    <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
    <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
  <![endif]-->
</head>
<body>
  <nav class="navbar navbar-default navbar-fixed-top">
    <div class="container">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed"
          data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="/chat"><%- @title %></a>
      </div>
      <div id="navbar" class="collapse navbar-collapse">
        <ul class="nav navbar-nav">
          <li><a href="/version">About</a></li>
        </ul>
      </div><!--/.nav-collapse -->
    </div>
  </nav>

  <div class="container">
    <% content %>
  </div>

  <footer class="footer">
    <div class="container">
      <% include 'views/footer.ect' %>
    </div>
  </footer>

  <script src="/jquery/jquery.min.js"></script>
  <script src="/socketio/socket.io.js"></script>
  <script src="/knockout/knockout.js"></script>
  <script src="/js/client.js"></script>
</body>
</html>

socket.io.js, knockout.js を追加。



chat.ect

<% extend 'views/layout.ect' %>

<div class="row" data-bind="visible: !user.isLogin()">
  <form class="form-inline" id="login-form">
    <div class="form-group">
      <label for="username">お名前</label>
      <input type="text" class="form-control" id="username" placeholder="User Name"
        data-bind="value: user.username">
    </div>
    <button class="btn btn-primary" id="login"
      data-bind="click: user.login">ログイン</button>
  </form>
</div>
<div class="row" data-bind="visible: user.isLogin()">
  <form class="form-inline" id="login-form">
    <div class="form-group">
      <p class="form-control-static">ようこそ <span data-bind="text: user.username"></span> さん!</p>
    </div>
    <button class="btn btn-xs btn-default" id="logout"
      data-bind="click: user.logout">ログアウト</button>
  </form>
</div>
<div class="row" data-bind="visible: user.isLogin()">
  <form class="form-inline" id="chat-form">
    <div class="form-group">
      <label for="message">メッセージ</label>
      <input type="text" class="form-control" id="message" placeholder="Message"
        data-bind="value: message.message">
    </div>
    <button class="btn btn-primary" id="send"
      data-bind="click: send">送 信</button>
  </form>
</div>
<div class="row" data-bind="visible: user.isLogin()">
  <div class="panel panel-default">
    <div class="panel-heading">チャットルーム</div>
    <div class="panel-body" id="response">
      <div class="row" data-bind="foreach: messageList">
        <div class="col-xs-1" data-bind="text: date"></div>
        <div class="col-xs-1" data-bind="text: username"></div>
        <div class="col-xs-10" data-bind="text: message"></div>
      </div>
    </div>
  </div>
</div>

上から順に、

  • ログインフォーム (ログイン後に隠す)
  • ログアウトボタン (ログイン後に表示)
  • メッセージフォーム (ログイン後に表示)
  • チャット欄 (ログイン後に表示)

knockoutjs のデータバインドでデータをやり取りするように実装。



js

// client.js

// user model
var User = function User(chat){
  var self = this;

  self.username = ko.observable('');
  self.isLogin = ko.observable(false);

  self.login = function(){
    // 接続処理
    self.isLogin(true);
    chat.login(self.username());
  };

  self.logout = function(){
    // 切断処理
    chat.logout(self.username());
    self.isLogin(false);
    self.username('');
  };
};

// message model
var Message = function Message(prm, chat){
  var self = this;

  var opts = {
    date: '',
    username: '',
    message: ''
  };
  if (!prm) {
    prm = {};
  }
  $.extend(opts, prm);

  self.date = opts.date;
  self.username = opts.username;
  self.message = ko.observable(opts.message);

  self.send = function(){
    if (chat) {
      // メッセージ送信
      chat.send({ message: self.message() });
      self.message('');
    }
  };
};

// Chat class
var Chat = function Chat(messageList){
  var self = this;
  var socket = io.connect();

  socket.on('connect', function(){
    console.log('connect');
  });
  socket.on('disconnect', function(){
    console.log('disconnect');
  });

  self.login = function(username){
    socket.emit('login', { username: username });

    socket.on('message', function(data){
      console.log(data);
      messageList.push(new Message({
        date: data.date,
        username: data.username,
        message: data.message
      }));
    });
  };

  self.send = function(msg) {
    socket.emit('message', msg);
  };

  self.logout = function(username) {
    socket.emit('logout', { username: username });
    socket.off('message');
  };
};

// application view model
var AppViewModel = function AppViewModel(){
  var self = this;
  // 受信したメッセージ
  self.messageList = ko.observableArray([]);
  // socket.ioのラッパー
  var chat = new Chat(self.messageList);
  // models
  self.user = new User(chat);
  self.message = new Message({}, chat);

  // 送信
  self.send = function(){
    self.message.send();
  };
};


$(function(){
  ko.applyBindings(new AppViewModel());
});

socket.io の処理は Chat クラスに押し込んだが、結果としてコードがゴチャゴチャになった気がする…



2015/11/11

koa + ECT による Webアプリケーション

koa

koa は Expressのチームによって設計された、新しいWebフレームワーク。

ES2015generator/yield を使用することにより、Expressでよくある callback地獄を回避し、簡潔にコーディング出来るようになった。


ECT

ECTejsjade のような templateエンジン。
非常に早い事が特徴。






必要なモジュールのインストール

npm で必要なモジュールをインストール。

$ mkdir web-app
$ cd web-app
$ npm init
  # Enter連打
$ npm install --save koa koa-route koa-static koa-views ect


  • koa-route : ルーティングを行う、koa のミドルウェア。
  • koa-static: 静的ファイルを公開するための koa のミドルウェア。
  • koa-views : テンプレートエンジンを使用してレスポンスを組み立てるための koa のミドルウェア



フォルダ構成

以下の様なフォルダ構成を作成。

{PROJECT_ROOT}
  |- public
  |    |- css
  |    |- js
  |    `- images
  `- views


app.jsの作成

プロジェクトのルート直下に app.js というファイルを作成し、
そこにコーディングしていく。

app.js

// app.js
var app = require('koa')();
var route = require('koa-route');
var serve = require('koa-static');
var views = require('koa-views');

// ectをテンプレートエンジンとして指定
app.use(views(__dirname + '/views', {
  map: {
    html: 'ect'
  }
}));

// GET /test
app.use(route.get('/test', function *version(){
  yield this.render('test.ect', {
    title: 'TEST APPLICATION',
    version: '1.0.0'
  });
}));

// static files
app.use(serve(__dirname + '/public'));

app.listen(3000);

http://localhost:3000/test にリクエストがあった場合に views/test.ecttitleversion を埋め込んで、レスポンスとして返す。



Viewの作成

viewファイルを作成していく。

views/layout.ect

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title><%- @title %></title>
  <link rel="stylesheet" href="/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/style.css">
  <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
  <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
  <!--[if lt IE 9]>
    <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
    <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
  <![endif]-->
</head>
<body>
  <nav class="navbar navbar-default navbar-fixed-top">
    <div class="container">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed"
          data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="#">Project name</a>
      </div>
    </div>
  </nav>

  <div class="container">
    <% content %>
  </div>

  <footer class="footer">
    <div class="container">
      <% include 'views/footer.ect' %>
    </div>
  </footer>
</body>
</html>

ECT 単体で使用した場合、include では ファイルが同一階層にあれば <% include 'footer' %> で取り込めるが、 koa のテンプレートエンジンとして使用した場合、ファイルの指定を app.jsからの相対パス とし、ファイルの拡張子を指定しないとViewファイルが見つけられず、例外が発生する。

public に配置された css などは、koa-static によって / (ルート) に公開されている。
<link><script> のパス指定は /css/{file_name}, /js/{file_name} とルートからのパスを指定する。


views/footer.ect

<p class="text-muted">
  &copy; <%- (new Date()).getFullYear() %> -
  <a href="https://kazunori-kimura.github.io/" target="_blank">Kazunori.Kimura</a>
</p>

コピーライトの年表示に JavaScript を埋め込んでいる。


views/test.ect

<% extend 'views/layout.ect' %>

<div class="jumbotron">
  <h1><%- @title %></h1>
  <p>version info: <%- @version %></p>
</div>

app.js でViewに渡されたパラメータをHTMLに埋め込む。

layout.ect での include と同様に、 extend でレイアウトファイルを指定する場合も app.jsからの相対パス とする必要があることに注意。

実行

$ node app.js

nodejs のバージョンが 0.12.x 未満の場合は --harmony オプションが必要になる。

アプリを実行したら、ブラウザで http://localhost:3000/test にアクセスし画面が表示されることを確認する。




2015/07/14

ナビゲーションバーの高さを変更する


Storyboardで作成した画面のナビゲーションバーの高さを変更したくて色々調査したので、結果をまとめておきます。

  • まず、storyboardで画面遷移を定義します。
    当然 Navigation Controllerを配置します。

  • NavigationBarのカスタムクラスを作成します。

-sizeThatFits: でNavigationBarのサイズを返します。
defaultのheightは 44.0 です。

また、高さを拡大すると、すべてのsubviewの上部分がその分だけ空いてしまうので、NavigationBarの中央に来るよう位置を変更します。

@implementation XXCustomNavigationBar

- (CGSize)sizeThatFits:(CGSize)size
{
  CGSize barSize = [super sizeThatFits:size];
  barSize.height += 20.0f; // 高さを変更
  return barSize;
}

- (void)layoutSubviews
{
  [super layoutSubviews];

  // vertically center
  float centerY = self.bounds.size.height / 2.0f;
  for (UIView *view in self.subviews) {
    // 位置変更
    CGPoint center = view.center;
    center.y = centerY;
    view.center = center;
  }
}
@end
  • 作成したカスタムクラスをNavigation Controllerに紐付けます

Storyboard にて Navigation Controller Scene -> Navigation Controller -> Navigation Bar を選択します。

Custom Class で 作成したカスタムクラス (この例では XXCustomNavigationBar) を指定します。


これで実行すると、Navigation Barが指定した高さで表示されるはずです。


2015/07/13

UIScrollViewのスクロール関連のプロパティについて

UIScrollView のスクロール関連のプロパティについて。


スクロール方向を固定する

斜め方向のスクロールを制限する場合、UIScrollViewdirectionalLockEnabledYES をセットする。

ただ、まれに斜め方向にスクロールしてしまう (正確に斜め方向にドラッグされた場合?)。
ドラッグ開始前後の位置を取得し、移動後にスクロール位置を矯正するよう実装する。

以下の UIScrollViewDelegate を使用する。

  • - (void)scrollViewWillBeginDragging:(UIScrollView*)scrollView
  • - (void)scrollViewDidScroll:(UIScrollView*)scrollView

参考: http://iphone-dev.g.hatena.ne.jp/tokorom/20101002/1285998723



追記

- (void)scrollViewWillBeginDragging:(UIScrollView*)scrollView だけでスクロール方向を取得する方法があったので、参考リンクを追加

UITableView/UIScrollViewでスクロール直後のスクロール方向を取得する



スクロール速度を変更する

正確にはスクロールが減速するスピードの比率。
UIScrollViewdecelerationRateUIScrollViewDecelerationRateFast を設定すると減速するスピードが早くなる (スクロール停止が早くなる→スクロール速度が遅くなったように見える)

scrollView.decelerationRate = UIScrollViewDecelerationRateFast;


参考: UIScrollView Class Reference



2015/07/06

UIImageViewにimageを設定する際に例外が発生する?

UIImageViewにimageを設定する際に例外!?

他人が作ったアプリのバグ修正依頼を受けた。

デバッグ実行してみると、
UIImageViewimageUIImage を設定しようとして、例外が発生していた。

[XxxxxView setImage:]: unrecognized selector sent to instance xxxxxxxxxx

unrecognized selector なので、メソッド名の指定に間違いがあるようだが…
ん、UIImageView でなく XxxxxView (該当画面のViewController) になってる?

ロジックを見てると、予め作成された UIImageView[self viewWithTag: tagNo] で取得していたんだが、条件によって tagNo0 になるパターンがあった。

for (int i=0; i<kX; i++) {
  for (int j=0; j<kY; j++) {
    NSInteger tagNo = [self getTagNoWithX:i Y:j]; // <- ここがおかしい
    UIImageView *img = (UIImageView *)[self viewWithTag: tagNo];
    img.image = [UIImage imageNamed:@"icon-name"];
  }
}

[self viewWithTag: 0] の時に 自分自身 (self) が返ってきてしまう模様。
適切なTagNoを返すように修正した。

2015/07/02

UIPopoverPresentationControllerが閉じる際にタップを連打するとアプリが落ちることへの対策

不具合内容

iPadアプリで、あるUITextFieldが選択されると、UIPopoverPresentationControllerでTableViewを表示して選択肢の中からユーザーに項目を選ばせる、という画面を実装していたところ、以下の様な不具合が発生した。

  • タップを連打すると、popoverがcloseするタイミングでエラーも出ずにアプリが落ちる
  • iOS 8.3, 8.4 では発生しない

最新のOSバージョンでは発生しないのでiOSの不具合と思われるが、どうしても対応して欲しいとのリクエストをお客さんからいただいたので、検討してみた。


対応方法

あまり時間を掛けたくなかったので、かなり場当たり的な対応だが、以下の方法で不具合を回避できた。

  • (BOOL)popoverPresentationControllerDidDismissPopover: にて、beginIgnoringInteractionEvents でタッチイベントを無効化する
  • 500ms後に endIgnoringInteractionEvents でタッチイベントを有効に戻す

コード例

#pragma mark - タップ連打防止
/**
 * iOS8.3未満の場合、タップを無効化する
 */
- (void)preventTapBarrage:(float)waitTime
{
  // iOS8.3未満の場合
  if ([[UIDevice currentDevice].systemVersion floatValue] < 8.3){
    // タッチイベントを無効にする
    [[UIApplication sharedApplication] beginIgnoringInteractionEvents];

    // 指定ms後、タッチイベントを有効に戻す
    [NSTimer scheduledTimerWithTimeInterval:waitTime
                                     target:self
                                   selector:@selector(enableTapEvents:)
                                   userInfo:nil
                                    repeats:NO];
  }
}

/**
 * タップを有効に戻す
 */
- (void)enableTapEvents:(NSTimer *)timer
{
  // タッチイベントが無効になっている場合
  if ([[UIApplication sharedApplication] isIgnoringInteractionEvents]){
    // タッチイベントを有効にする
    [[UIApplication sharedApplication] endIgnoringInteractionEvents];
  }
}

#pragma mark - UIPopoverPresentationControllerDelegate Methods
/**
 * iOS8.3未満において、popoverのclose時にタップ連打するとアプリが落ちるため
 * 一時的 (500ms) にタッチを無効化する
 */
- (BOOL)popoverPresentationControllerShouldDismissPopover:(UIPopoverPresentationController *)popoverPresentationController
{
  // 500msタッチ無効 (iOS version < 8.3 の場合のみ)
  [self preventTapBarrage:0.5f];
  return YES;
}