shimada-kの日記

ソフトウェア・エンジニアのブログです

UIScrollViewにおけるcontentOffsetの注意点

UINavigationControllerを使用している場合で、UIScrollViewをpagingで使用している時にコードからUILabelを置こうとした時に想定通りの座標で配置できなかったことがあったので解決手段をメモしておきます。

既知のトピックではありますが、ちゃんとまとまった情報に出会えなくてかなりハマったので書いておきます。

これは2014年iOSアドベントカレンダー5日目の記事として書かれました。

環境

コード

#import "ViewController.h"

@interface ViewController ()
@property (weak, nonatomic) IBOutlet UIScrollView *scrollView;

@end

#define NUM_LABEL 5

@implementation ViewController

- (void)viewDidLayoutSubviews {
    NSLog(@"viewDidLayoutSubviews");
    [super viewDidLayoutSubviews];
    
    //スクロールの総範囲の設定
    NSInteger kScrollObjWidth = [[UIScreen mainScreen] applicationFrame].size.width;
    [self.scrollView setContentSize:CGSizeMake(NUM_LABEL * kScrollObjWidth, self.scrollView.frame.size.height)];
    // 謎に下にずれることがあるのでy座標を0にする
    NSLog(@"contetOffset (%f, %f)", self.scrollView.contentOffset.x, self.scrollView.contentOffset.y);
    self.scrollView.contentOffset = CGPointMake(self.scrollView.contentOffset.x, 0);
}

- (void)viewDidAppear:(BOOL)animated
{
    NSLog(@"viewDidAppear");
    [super viewDidAppear:animated];
    //[self showObjects];
}

- (void)viewDidLoad {
    NSLog(@"viewDidLoad");
    [super viewDidLoad];
    self.scrollView.contentOffset = CGPointMake(self.scrollView.contentOffset.x, 0);
    // Do any additional setup after loading the view, typically from a nib.
    [self showObjects];
}

- (void)showObjects{
    NSInteger kScrollObjWidth = [[UIScreen mainScreen] applicationFrame].size.width;
    NSInteger i;
    
    for(i = 0; i < NUM_LABEL; i++){
        UILabel *label = [[UILabel alloc] init];
        
        label.frame = CGRectMake(kScrollObjWidth * i, 0, kScrollObjWidth, 30);
        
        label.textAlignment = NSTextAlignmentCenter;
        label.text = [NSString stringWithFormat:@"%ld", (long)i];
        
        label.backgroundColor = [UIColor greenColor];
        
        [self.scrollView addSubview:label];
    }
}

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}

@end

このコード。上記のようにshowObjectsをviewDidLoadで呼び出すと下記のようになります。その場合、呼び出す前にcontentOffsetの初期化をやっても同様です。

オレンジ色のものがUIScrollViewで、緑色のものがUILabelです。

f:id:shimada-k:20141203013948p:plain

また、viewDidLayoutSubviewsでcontentOffsetを初期化しない場合でも同じ結果になります。コードからではなくInterface Builderでオブジェクトを置いている場合でも同様です。

ちなみに上記の状態でもマウスか指でスクロールして何かしらの動きを与えると座標が0の状態(想定通りの状態)に戻ります。

発生理由と対策

発生理由はNavigationControllerVilew配下にScrollViewを置いた場合、ContentsOffset.yのデフォルト値が-64になっているからです。

f:id:shimada-k:20141203012446p:plain

-64というのはステータスバーの20ptとナビゲーションバーの44ptの合計値で、NavigationController配下にあった時UIScrollViewのコンテンツが隠れてしまわないようにという配慮です。これはUIScrollViewの仕様です*1

ちなみにナビゲーションバーが存在しない状態だとcontentsOffsetの初期値は(0.000000, -20.000000)とはならずに(0.000000, 0.000000)となります。

解決するには

  • UIScollViewが配置され(てい)るUIViewControllerのautomaticallyAdjustsScrollViewInsetsをNOにする

もしくは

  • UIScrollViewのcontentsOffsetをviewDidLayoutSubviewsで初期化した後にshowObjectsを呼ぶ

必要があります。例えばcontentsOffsetをviewDidLayoutSubviewsを初期化してshowObjectsをviewDidAppearで呼び出すようにするとscrollViewの左上からちゃんとUILabelが表示されます。

f:id:shimada-k:20141203014005p:plain

この件はUIScrollViewに対してConstrainsを設定していても発生します。またタップすると(0, 0)に戻るという点と、ナビゲーションバーが存在しない状況では発生しないという点が邪魔をして、解決に時間がかかりました。

東京メトロのアプリコンテストに応募しました

iOSアプリを作成しアプリコンテストに応募しました。作成から応募までのログを宣伝を兼ねて書いておきます。

東京メトロ オープンデータ活用コンテスト

東京メトロAPIを使用したアプリを作るという趣旨です。サマって書くと個人・団体問わず参加OK、一人何作品出してもOK。Web/iOS/Android/WindowsPhoneで作ってね、賞金出すよ。というもの。僕らはiOSアプリを作りました。選考要素にデザインも含まれていたので社内のクリエイティブの友達を誘いプライベートの時間を使って2人体制で開発しました。

作ったもの


MetroCluster紹介動画 - YouTube

foursqureのAPIを使って電車の運行状況に応じて到着駅付近のvenueの画像と地図を表示するアプリを作りました。socket.ioを使ったチャット機能もつけてアプリのユーザ同士で会話できる機能もつけました。

感想

かなり詰みました。着手したのが9月末からで、スケジュールを切ったところAppleの審査(このことは後で詳細を書く)があるので10月中にベータ版を作ることに。開発期間は実質1ヶ月という状況。10月は仕事以外でも予定が多い月だったので開発時間の捻出に苦労しました。

Apple審査

マイルストーンに従ってβ版を10月下旬に審査に出しました。まあベータ版なので速攻でリジェクトを食らいました。ベータ版を修正&機能追加した版を再度11/2に審査に提出しました。

コンテストの応募締め切りが11月17日だったので最悪Apple審査が締め切りに間に合わない可能性があると思いコンテストの運営MLに審査が間に合わない場合の運営のケアはあるかどうか問い合わせました。

結果はNG。iOSアプリの場合、応募時点でAppStoreに公開さていることが条件と言われました。まあダメ元で聞いてみたのであまり期待してませんでした。

Apple次第ということで待っていると11月11日にApple審査が通りました。Ready for Sale。これで応募に関しては気に病むことはないと安堵したのもつかの間。AppStoreから落としてきたアプリを起動するとクラッシュ。メイン画面まで辿り着かないという状況に。

原因箇所は位置情報の取得を待つところで、無限ループに突入していました。申請前に一度アプリをアンインストールしてテストしなかったのが原因でこのバグを捕捉できていませんでした。

Appleの審査は新規よりもアップデートの方が申請通過が早いらしいという情報は事前に得ていたのでもうその可能性に賭けるしかないと思い、速攻で修正し再度申請。この時点で申請は3回目。日付は11/12日。

ただそう甘く無いらしく応募2営業日前になってもiTunes Connectは音沙汰なし。これはもうしょうがないと思い、緊急レビューの禁じ手を使う覚悟を決めました。緊急レビューは依頼しても確実にやってくれるものではないので、一旦Appleの返答待ちになりました。

緊急レビュー依頼の結果は、NG。まあ個人都合だから断られてもしかたないと思いつつも、絶望。

もう手を尽くしたので、あとはコンテストの運営側を説得するしかないと思い開発者サイトを見ると、iOSアプリケーションの場合は締め切りまでにAppleにSubmitしたバイナリに関しては審査対象とする旨のアナウンスが!

僕が問い合わせたように他の応募者からも同様の問い合わせが来ていたようで、ダメ元で問い合わせたのが良かったのか、iOSアプリを作成している応募者は応募時と審査通過時にiTunes Connectのステータス履歴をPDFにしたものを送ればコンテストの運営側が確認するのでOKとのこと。

もう神に救われた気分でしたよ。ええ。

それからは直したい部分の改修と時間がなくて追加できなかった機能を追加してコードフリーズ。前回の失敗を活かし申請前に地下鉄に乗ってデバッグ。締切日が月曜日だったので前日までに申請と応募の素材と文章をそろえました。

そして本日無事Apple申請に通過しました。実際にDLしてみたところ前回のようにクラッシュするケースはありません。かなりバタつきましたが作成したアプリをApple申請に出したのは初めての経験だったので勉強になりました。

神から祝福を受けたアプリなので是非使ってみてください。

MetroCluster

MetroCluster

  • KATSUYA SHIMADA
  • ライフスタイル
  • 無料

2015-07-20 追記

上記アプリはAPIの公開期限が過ぎたためAppストアから削除しました

Synthでejsを使う

Synthでテンプレートエンジンにejsを使用する時のメモ。Synthのバージョンは0.5.2です。

back/node_modules/synth/synth.jsに追記

back/package.jsonにejsの項目を追記してsynth install -bを実行します。その後、back/node_modules/synth/synth.jsにejsを使うように変更します。

/* the main synth init function */
exports = module.exports = function (options) {
  options = options || {};
  var defaultResourceDir = path.join(process.cwd(), 'back/resources');
  var viewDir = options.viewDir || path.join(process.cwd(), 'front');
  //var viewEngine = options.viewEngine || 'jade';
  var viewEngine = options.viewEngine || 'ejs';

front/index.ejsを作成

index.jadeがレンダリングした結果をもとに勝手にejs化しました。

<!DOCTYPE html>
<html ng-app="my_app">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title><%= appName %></title>
        <% for(var i = 0; i < cssFiles.length; i++){ %>
            <link rel="stylesheet" href=<%= cssFiles[i] %>>
        <% } %>

        <!-- Preloaded Data-->
        <script>
            var preloadedData = <%- data %>;
        </script>

        <% for(var i = 0; i < jsFiles.length; i++){ %>
            <script type="text/javascript" src="<%= jsFiles[i] %>"></script>
        <% } %>
    </head>
    <body>
        <h1><%= appName %></h1>
        <div ng-view>
            <% if(preloadHTML){ %>
                <script type='text/ng-template', id=<%= preloadHTMLPath %>>
            <% } %>
        </div>
    </body>
</html>

node_modules/synth/lib/frontendRenderer.jsの改修

front/index.ejsとfront/html/hoge/getIndex.ejsを読みに行くようにコードを書きます。まずはejsをrequireしましょう。

var path = require('path'),
    Promise = require('bluebird'),
    fs = Promise.promisifyAll( require('fs') ),
    jade = require('jade'),
    ejs = require('ejs'),
    assets = require('./assets.js');

64行目付近に拡張子を置換してjadeファイルを読みに行っている場所があるので、ejsに置換するコードを追加し、さらにejs.renderFileを使ってejsファイルを読んであげるようにします。

var readHTML = function (path) {
  var production = process.env.NODE_ENV == 'production';
  return fs.readFileAsync(path).error(function (err) {
    if (err.cause.code == 'ENOENT') {
      // Couldn't find the HTML version, how about jade?
      ejs.renderFile(path.replace(/\.html$/, '.ejs'), {}, function(err, result) {
        if(!err){
            console.log(result);
        }
        else{
            console.log(err);
        }
      });
      //return jade.renderFile( path.replace(/\.html$/, '.jade'), {
      //  pretty: !production
      //});
    } else throw err;
  })
  .catch(function (err) {
    if (err.code != 'ENOENT') throw err;
    return null;
  });
};

front/html/tweets/getIndex.ejs追加

frontendRenderer.jsを書き換えてもファイルを作らないと読んでもらえないのでファイルを作りましょう。これもindex.ejsと同様にjadeでレンダリングされたあとのHTMLを参考にejs化しました。

<ul class="tweet-timeline">
  <li ng-repeat="tweet in tweets" class="tweet">
    <div class="content">{{ tweet.content}}</div>
    <div class="date">{{ tweet.created_at | date:'medium' }}</div>
  </li>
</ul>

以上です。あとはsynth sしてlocalhost:3000/tweetsにアクセスすればデフォルトのjade版と変わらない形でレンダリングされるはず。

UnityでS.S.デッドリー・ボンバーを作ってみる

人造人間13号*1が放つS.S.デッドリーボンバーをUnityで再現してみました。人造人間13号はドクター・ゲロが使っていたマシンが悟空抹殺のために自動で作りだした人造人間です。

人造人間13号

構造は、まず真ん中のコアのような球体と、その外側に赤い帯が表示されている球体、さらに一番外側には透明な球体の3つのsphereオブジェクトを作成しました。赤い帯のような模様はテクスチャで表現しました。

透過テクスチャなのでサーフェスシェーダーでマルチパスレンダリングを行います。シェーダーのコードはこちらを参考にしました。

1番外側のsphereには6個のparticle systemを子オブジェクトに設定してあります。原作のようにサイズ差のあるパーティクルにするためにstartSizeをCurveで変更してあります。

6個のparticle systemのうち4個をスクリプト上で180フレームごとにランダムに出し分けています。

using UnityEngine;
using System.Collections;

public class SSDBOuterController : MonoBehaviour {

    public GameObject[] particle_lines;

    private System.Random r;
    private int[] particle_status = new int[6];
    private int particle_interval = 0;

    // Use this for initialization
    void Start () {

        r = new System.Random(1000);

        for(int i = 0; i < particle_lines.Length; i++){
            particle_lines[i].particleSystem.Pause();
        }

    }
    
    // Update is called once per frame
    void Update () {

        if(particle_interval < 180){
            gameObject.transform.Rotate (new Vector3((float)r.Next (2, 8), (float)r.Next (2, 8), (float)r.Next(2,8)));
            particle_interval++;
        }
        else{
            // 一旦すべて表示状態にする
            for(int i = 0; i < particle_status.Length; i++){
                particle_lines[i].particleSystem.renderer.enabled = true;
                particle_status[i] = 0;
            }

            int counter = 0;

            // ランダムで1/3を選んで消す
            while(counter < (particle_lines.Length / 3)){
                int index = r.Next(0, particle_lines.Length - 1);
                if(particle_status[index] == 0){
                    particle_status[index] = 1;
                    particle_lines[index].renderer.enabled = false;
                    counter++;
                    //Debug.Log (index);
                }
            }
            particle_interval = 0;
        }
    }
}

赤い帯の回転と周りの光の筋の回転が同期していないのが原作において仕様であることと、particle systemを固定座標で回転させると見た目がワンパターンになってしまうのでこのような実装にしました。

さらに原作では光の筋の長さが一定ではないため、particle systemの設定を6本のうち2本だけ変えて2倍の長さにしてあります。

f:id:shimada-k:20140831011614p:plain

DEMO

赤い帯のsphereと真ん中のコアの周りにただよう拡散光はHaloで表現しました。

ついでにskyboxを設定しました。skyboxのテクスチャはImport Packageで取り込まれるデフォルトのテクスチャです。

私はドラゴンボールに出てくるエネルギー波の中で、人造人間13号が放つS.S.デッドリーボンバーとピッコロの魔貫光殺砲の2つが単体でのビジュアルに最も富んでいるものだと思っています。

コードと素材一式はgithubに置いておきました。

この惑星(ほし)の半分を吹き飛ばすほどのパワーがあるのだぞ!!!

Synthを試してみる

先日東京Node学園14時限目に参加してきました。そこで@pchwさんから紹介があったSynthを試してみました。試してみた分かったことを書いておきます。

SynthはSPA作成を念頭において設計されたWebフレームワークです。

Angular.jsでサイトを作成した場合、HTMLの基本構造だけロードして中のデータは後からレンダリングされます。それだと検索エンジンフレンドリーではないというのと、APIロード完了までに空のdivやtableが表示されてしまってしまうという課題があったので、その課題に対するアプローチの1つということです。

Synthを使用しない(Angular.jsだけで実装した)場合と使用した場合のソースをさらしておきます。ちょくちょく出てくるRGBというプリフィックスやgridといったワードはプライベートプロジェクト由来のものなので特に他意はありません。

Synthを使用しない場合

テンプレート

<html ng-app>
    <head>
        <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
        <script src="angular.min.js"></script>
        <script src="05.js"></script>
    </head>
    <body ng-controller="RgbController">
        <table class="table">
            <tr>
                <th>Id</th>
                <th>Name</th>
                <th>Platform</th>
                <th>Comment</th>
                <th>IsCold</th>
            </tr>
            <tr ng-repeat="rgb in rgbjson">
                <td>{{rgb.id}}</td>
                <td>{{rgb.name}}</td>
                <td>{{rgb.platform}}</td>
                <td>{{rgb.comment}}</td>
                <td>{{rgb.is_cold}}</td>
            </tr>
        </table>
    </body>
</html>

コントローラー側です。

function RgbController($scope, $http, $timeout) {
    var uri = 'http://192.168.3.2/rgb/rgb.json';
    var param = {num: 200};

    $http.get(uri, param)
         .success(function(data, status, headers, config) {
             log('success', data, status, headers, config);
         })
         .error(function(data, status, headers, config) {
             log('error', data, status, headers, config);
         });

    // jsonを表示する
    function log(type, data, status, headers, config) {
        // delay
        $timeout(function(){
                    $scope.rgbjson = data;
                    }, 5000);
        //$scope.rgbjson = data;
    }
}

Synthを使用した場合

サーバ側。チュートリアルのコードをベースにしています。Promiseオブジェクトを返す必要があるのでmysql-promiseをnpm installしました。

back/resources/rgb/getRgb.js

exports.getIndex = function (req, res) {

    req.db.configure({
        "host": "localhost",
        "user": "xxxx",
        "password": "xxxxxxxx",
        "database": "rgb"
    }); 

    return req.db.query('SELECT * FROM grid_image').then(function (rows) {
        console.log(rows[0]);
        return { data:rows };
    }); 
};

クライアント側。コントローラーから

front/controller/rgb.js

angular.module('my_app')
.controller('rgbController', function ($scope, $http, data) {
    //console.log(data.data[0]);
    $scope.rgb_entries = data.data[0];
});

続いてビュー

front/html/rgb/getIndex.jade

ul.rgb-timeline
    li.grid(ng-repeat="entry in rgb_entries").content {{entry.id}} {{entry.name}} {{entry.comment}}

総括

私はVirtualBox仮想マシン上に環境を作りましたが、ファイルサイズがデフォルトの8GBだとチュートリアルで使用するmongodbにデータを作るgenerateTeweet.jsがディスク容量不足で失敗しました。VMで試す場合はディスクサイズを余裕を持った値にしておくと詰まずに済みます。ちなみにVMのファイルサイズの変更はこちらを参考に行いました。

preloadを試すときはAPIの返り値をPromiseオブジェクトにしないといけませんが、現時点(2014年8月24日)で公式サイトで提供しているNodeはv0.10なのでPromiseをサポートしていません。

ならばと思いgithubのmasterブランチにのっているものをビルドすると-preが付いているバージョンはnode-sassモジュールをインストールできないと怒られ、Synthサーバを起動できませんでした。Promiseが公式にサポートされる(であろう)Node v0.12のリリースを待ちましょう。

Synthサーバを立ち上げている時にサーバ側のコードを変更すると変更を検知して自動でサーバを再起動してくれるのはうれしいです。

普段Expressを使っていて、ライアントサイドとサーバサイドのコードがごっちゃになっているのはいかがなものかと思っていたのでfront/とback/でディレクトリが明確に別れている構成は好感が持てます。

Firefoxの新機能Loopを試す

WebRTC Meetup Tokyo #3に参加してきました。@chikoskiさんから紹介があったFirefoxの新機能のLoopを試してみました。

LoopはWebRTCを使用して実装されたビデオチャットサービスで、Firefoxのアルファ版のAuroraで試すことができます。

f:id:shimada-k:20140803072409p:plain

基本的な使い方はGoogle+ハングアウトと似ていて、生成されたURLを相手と共有してチャット開始といった感じです。

f:id:shimada-k:20140803072534p:plain

Firefox Auroraはメニューに電話アイコンがあるのでそのアイコンをクリックするとURLが生成されます。

f:id:shimada-k:20140803072916p:plain

生成されたURLを別のブラウザで開くとチャットの画面が表示されるのでボタンをクリックするとWebRTC特有のデバイスアクセスの許可を求めるポップアップが表示されるので許可するとビデオチャットが開始されます。

LoopはGoogle+ハングアウトとは違い、ブラウザの組み込み機能として実装されています。

FirefoxOSで動くアプリと連携できるようにそういう設計にしたというのと、Web-APIの拡充を啓蒙したいというMozillaさんのもくろみがあるのかもしれません。

とはいえ、デスクトップアプリとしても動作していて、ブラウザからウィンドウを切り離してSkypeのように使用することもできます(この辺はGoogle+ハングアウトも同様ですが、、)。

f:id:shimada-k:20140803074429p:plain

アルファ版ということもあってまだ機能はシンプルですがWebRTCを使ったサービスが増えていくのはいいと思います。あとはSafariIEでもWebRTCがちゃんと動くようになればもっといいですね!

UnityでLeap Motionが認識した手を動かす

UnityでLeap Motionのビジュアライザーのように認識した手を動かしてみました。手は以下のオブジェクトで表現しました。

  • 手首:Cube
  • 手のひら:Sphere
  • 間接:Sphere
  • 骨:Line Renderer

Leap MotionSDKのバージョンはもちろんv2 Betaです。Unity側スクリプト言語C#を使いました。ソース一式はgithubにあげてあります。

f:id:shimada-k:20140726225215p:plain

以下ハマった点

1つめ

正規化した座標をUnityのオブジェクトに適応させるときにlocalPositionに代入していましたが、それだと間接の座標だけ他と位置が離れてしまって、パーツの配置に違和感が生じていました。positionに代入しないといけませんでした。

unityWrist.transform.position = utils.getScaledUnityPosition(hand.WristPosition,
                                                                interactionBox);

utils.getScaledUnityPositionは自作dllの関数です。

2つめ

手首のCubuオブジェクトの回転は直近フレームを保存して、RotationAngleでxyzの軸別に差分角度を取得してtransform.Rotateで動かす方法をとっています。

Unityでオブジェクトを回転させる時はtransform.rotationで絶対値を代入するとか、Quaternion.Slerpを使うとかいくつか方法があるようですが、Leap MotionSDKのAPIと相性がいい方法をチョイスしました。

Hand.RotationAngleで直近フレームとの差分の角度を取得するんですが、こいつがラジアン角で、Unityのtransform.Rotateで回転させるためにはオイラー角である必要があります。

なのでラジアンオイラーに変換する必要があったんですが、そこに気づくのに半日かかりました。

float angleX = hand.RotationAngle(basisFrame, basis.xBasis);
float angleY = hand.RotationAngle(basisFrame, basis.yBasis);
float angleZ = hand.RotationAngle(basisFrame, basis.zBasis);

// 回転する
unityWrist.transform.Rotate(new Vector3(
                                LMUtils.rot2Dir(angleX),
                                LMUtils.rot2Dir(angleY),
                                -1 * (LMUtils.rot2Dir(angleZ))));

LMUtils.rot2Dirは自作dllの関数です。z方向に-1をかけているのは認識した手の向きを変えるためです(positionも反転させています)。

動作中のスクリーンをキャプチャしました。Line RendererがPalmSphereを貫通してますがそこは気にせず。(Leap Motionのビジュアライザーだと第3間接のSphereを横につなげて見た目を整えてますが、、)。

両手対応してます。ソースは1つにしたかったので、Unityのオブジェクトにタグをふっておいて、スクリプト側ではHandListでforeachしてタグとisLeftやisRightの結果を比較して描画し分けています。

Line Rendererで骨を表現するとlengthを気にしなくていいので楽です。あと親指は他の指より間接が一つ少ない(というかlengthが0の骨がある)ことを考慮する必要あります。

ベースはできたのでこれからUnityとLeap Motionを遊び尽くす。