タグ別アーカイブ: cocoa

[Swift]UITableViewで無限スクロールしてみる

WEBAPIから取得したブログエントリーを無限スクロールで表示するビューアを作成する事になったので、
UITableViewを使って実装のサンプルを作ってみました。

実装の概要
・UITableViewのスクロールに応じて必要なデータを必要なタイミングでロード。
・上にスクロールした場合にはデータを先頭に、下にスクロールした場合にはデータを末尾に追加する
・データの変更時にはUITableViewの表示位置(contentOffset)を調整する
・セルの高さは動的に決定する(行毎の高さはキャッシュする)

表示用のデータはこんなものを想定します。

class MyData
{
    var date:NSDate;
    var text:String;
    
    init(date:NSDate,text:String){
        self.date = date;
        self.text = text;
    }
}

dateが並び順(タイトル)で、表示コンテンツとしてtextがあります。

で、この情報をUITableViewに表示するのですが、今回のサンプルはコンテンツの内容に応じてセルの高さを動的に決定しようと思います。
高さを求めるのはそれなりにコストが高いので、計算した高さを表示用データとペアにして格納するためのクラスを定義します。

class CellData<T> {
    // 表示データ
    var data:T
    // セルの高さ
    var height:CGFloat?
    
    init(data:T,height:CGFloat?){
        self.data = data
        self.height = height
    }
}

セルの高さを求める時には表示用のコードと殆ど同じになりますので、実際のセルに計算させると良いかと。
ですので、こんな感じのUITableViewCellを定義します。

class MyTableViewCell: UITableViewCell
{
    private var titleLbl:UILabel!
    private var textLbl:UILabel!
    
    // 計算済の高さが設定される
    var requiredHeight:CGFloat = 0
    
    
    override init(style: UITableViewCellStyle, reuseIdentifier: String!)
    {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        self.setupSubviews()
    }
    
    required init?(coder aDecoder: NSCoder)
    {
        super.init(coder: aDecoder)
        self.setupSubviews()
    }

    func setupSubviews()
    {
        self.titleLbl = UILabel()
        self.titleLbl.font = UIFont.boldSystemFontOfSize(15)
        self.addSubview(self.titleLbl)
        
        self.textLbl = UILabel()
        self.textLbl.font =  UIFont.systemFontOfSize(13)
        self.textLbl.numberOfLines = 0
        self.addSubview(self.textLbl)
    }
    
    // 表示用のデータを設定
    // 必要な高さも求めてrequiredHeightに保存
    func setData(data:MyData)
    {
        titleLbl.frame = CGRectMake(5, 5, self.frame.width - 5 * 2.0, 18)
        titleLbl.text = "\(data.date)"
        
        
        self.textLbl.frame = CGRectMake(5, titleLbl.frame.origin.y + titleLbl.frame.height + 5, self.frame.width - 5 * 2.0, 10)
	// 設定した文字列によってサイズを動的に変更
        self.textLbl.text = data.text
        self.textLbl.sizeToFit()
        
        // 必要な高さを保存しておく
        requiredHeight = textLbl.frame.origin.y + textLbl.frame.size.height + 5
    }

}

setDataでMyDataを渡すと、requiredHeightにセルを表示するために必要な高さが設定されます。

これらのクラスを使ってUITableViewを使います。
コードはこんな感じ!(ズババン!)

class SampleViewController:UIViewController,UITableViewDelegate,UITableViewDataSource {

    // MARK: - Properties
    
    var tableView: UITableView = UITableView()
    
    
    // テーブルに表示するデータを保持する
    // ※SortedList(→コード)を使っていますが、なんでも大丈夫です
    var tableData:SortedList<CellData<MyData>> = SortedList<CellData<MyData>>(compFunc: { (left, right) -> CompareResult in
        switch (left.data.date.compare(right.data.date)){
        case .OrderedSame: return CompareResult.Equal
        case .OrderedAscending: return CompareResult.RightLarge
        case .OrderedDescending: return CompareResult.LeftLarge
        }
    })


    // セルの高さを算出するための作業セル
    var workingCell = MyTableViewCell()
    
    // データ処理の実行フラグ
    var isLoading = false


    // MARK: - Initialize
    
    init() {
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    


    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.backgroundColor = UIColor.yellowColor()
        
        
        // テーブルの設定
        tableView.registerClass(MyTableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        tableView.delegate = self
        self.view.addSubview(tableView)
        
    
        // 初期データの読み込み  昨日から30日分のデータを取得して初期データとします
        let date = NSDate(timeInterval: 24 * 60 * 60 * -1, sinceDate: NSDate())
        self.loadDataFromDate(date, count: 30, toFuture: true, containTargetDate: true) {[weak self] (results) in
            
            self?.tableData.append(results)
            self?.tableView.reloadData()
            
            // 指定した日付が先頭に表示されるようにスクロール
            let indexPath = NSIndexPath(forRow: 1, inSection: 0)
            self?.tableView.selectRowAtIndexPath(indexPath, animated: false, scrollPosition: UITableViewScrollPosition.Top)
        }

    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()   
        tableView.frame = self.view.bounds;
    }
    
    override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated)
    }    
    
    override func viewWillDisappear(animated: Bool) {
        super.viewWillDisappear(animated)
    }


    // MARK: - 表示用データの取得
    
    // データの取得
    // DBやWebAPI使う事を前提に非同期処理としています
    func loadDataFromDate(date:NSDate,count:Int,toFuture:Bool
        ,containTargetDate:Bool,callback:(results:Array<CellData<MyData>>) -> ())
    {
        isLoading = true;
        // データ取得中はウエイトカーソル的なものを表示した方が良いかもね
 
        let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
        let mainQueue = dispatch_get_main_queue()
        weak var weakself = self
        
        dispatch_async(queue,{
            autoreleasepool {
                // ダミーデータを作成する
                var loaded = Array<CellData<MyData>>()
                let dayInterval = Double(24 * 60 * 60 * (toFuture ? 1 : -1))
                
                var dt = containTargetDate ? date : date.dateByAddingTimeInterval(dayInterval)
                
                while loaded.count < count {
                    
                    let csdata = CellData(data: MyData(date: dt, text: weakself!.makeRandomString()), height: .None)
                    
                    loaded.append(csdata)
                    
                    dt =  dt.dateByAddingTimeInterval(dayInterval)
                }
                // 少しウエイトしてみる
                NSThread.sleepForTimeInterval(0.5)
             
                dispatch_sync(mainQueue, {
                    callback(results: loaded)
                    weakself!.isLoading = false;
                })
                
            }
        })
    }
    

    // ランダムな文字列を生成する
    func makeRandomString() -> String {
        let wordTable = ["りんご","梨","ひよこ","ごりら","犬","を食べる","を投げる","を拾う","を蹴る","自分","へ怒る","を割る","コップ","テレビ"];
        let wordCount = arc4random_uniform(50) + 1
        
        let  result = (0..<wordCount).reduce("") { (v, no) -> String in
            let idx = Int(arc4random_uniform(UInt32(wordTable.count)))
            return v + wordTable[idx]
        }
        
        return result
    }
    

    // MARK: - UITableViewDelegate,UITableViewDataSource 
    
    func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return 1;
    }
    
    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return tableData.count
    }
    
    func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
        
        let obj = tableData[indexPath.row]
        if let height = obj.height {
            return height
        } else {
            // セルの表示サイズを計算してキャッシュする
            workingCell.setData(obj.data)
            let height = workingCell.requiredHeight
            obj.height = height
            return height
        }
    }
    
    
    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCellWithIdentifier("cell", forIndexPath: indexPath)        
        let obj = tableData[indexPath.row]
        
        if let csCell = cell as? MyTableViewCell {
            csCell.setData(obj.data)
        }
        
        return cell
    }

    
    func scrollViewDidScroll(scrollView: UIScrollView) {
        
        // データ取得中は処理しない
        if isLoading {
            return
        }
        
		// 事前読み込みを行う末端からの距離
		let margin:CGFloat = 100
        
        // 下方向への読み込み判定
        // ここの判定に一定のマージンを設ける事で事前読み込みが可能となります
        // このサンプルでは必要となったときにガバッと取得
        let bottomOffset = self.tableView.contentOffset.y + self.tableView.bounds.size.height
		let lengthFromBotton = self.tableView.contentSize.height - bottomOffset        
        if (lengthFromBotton >= margin){            
            let dt = tableData[tableData.count-1].data.date            
            self.loadDataFromDate(dt, count: 10, toFuture: true, containTargetDate: false) {[weak self] (results) in
                self?.tableData.append(results)
                self?.tableView.reloadData()
            }
        }
        

        // 上方向への読み込み判定
        if (self.tableView.contentOffset.y <= margin){            
            let dt = tableData[0].data.date            
            self.loadDataFromDate(dt, count: 5, toFuture: false, containTargetDate: false) {[weak self] (results) in
   		 // 上方向へのスクロールの場合にはスクロール位置の調整が必要
                var height:CGFloat = 0
                for v in results {
                    // セル毎の高さを求めるつつ、tableDataへ格納する
                    self!.workingCell.setData(v.data)
                    v.height = self!.workingCell.requiredHeight
                    height += v.height!                    
                    self?.tableData.append(v)
                }
                
                // 取得したセルの高さ(合計)だけスクロール位置を移動させる
                self?.tableView.contentOffset.y = self!.tableView.contentOffset.y + height
                
                self?.tableView.reloadData()
            }
            
        }
        
    }

}

ポイントは上方向にスクロール(先頭方向)した場合のスクロール位置の調整ですかね。
下方向にスクロールした場合にはデータを末尾に追加するのでスクロール位置は変更しなくて良いのですが、上方向の場合には今表示している位置より前にデータを追加することになります。
ですので、今の表示位置を読み込んだセルの高さ分だけ下に調整してあげる必要があります。

このサンプルではMyDate.dateで勝手にソートされるようにSortedListを使っていますが、普通のArrayでも大丈夫です。
上にスクロールするときは先頭にデータを追加、下にスクロールした時は末尾に追加すれば良いのです。

MKMapViewのコンパスを移動させる

MapViewを回転(方角を変更)すると右上にコンパスが表示されます。
こんなの↓
20150727_1

右上に表示されるもんですから画面上部にツールバーをフロートで表示させようとした時などにとっても邪魔!
しかもこれ、iOS9だと消せるらしいのですがiOS8だと消せないんです(たぶん)。
で、移動させちゃえって思ったのですが、そんなプロパティ見つかりませんし、、

ただ、一部のアプリでは標準と違う場所に表示されているので何かしら方法があるのではないかと思って色々調べていたらstackoverflowでこんな情報が見つかりました。

http://stackoverflow.com/questions/18903808/ios7-compass-in-mapview-placing

簡単に説明すると

  • MKMapviewはUILayoutSupportが示す位置を参考にしてコンパスとか著作権情報などの表示位置を決めている。
  • で、このUILayoutSupportってのはViewControllerのtopLayoutGuideとかbottomLayoutGuideで決める事ができる。
  • なので、いい感じの位置情報を返すUILayoutSupportを、topLayoutGuideとかbottomLayoutGuideで返せばOK!

 
 
実際のコードはこんな感じで

最初に UILayoutSupport を実装したクラスを定義します

class MapLayoutGuide: NSObject,UILayoutSupport {
    
    private var _length:CGFloat = 0;
    
    init(length:CGFloat){
        super.init();
        _length = length;
    }
    
    var length: CGFloat {
        get {
            return _length
        }
    }

    @available(iOS 9.0, *)
    var topAnchor: NSLayoutYAxisAnchor {
        return NSLayoutYAxisAnchor()
    }
    
    @available(iOS 9.0, *)
    var bottomAnchor: NSLayoutYAxisAnchor {
        return NSLayoutYAxisAnchor()
    }

    @available(iOS 9.0, *)
    var heightAnchor: NSLayoutDimension {
        return NSLayoutDimension()
    }
}

で、これを ViewController の topLayoutGuide で返すようにオーバーライド

override var topLayoutGuide: UILayoutSupport {
   get {
      return MapLayoutGuide(length: 50);
    }
}

するとコンパスが下図のように移動してくれます。

20150727_2

(2015.09.15) iOS9向けのコードを追加しました

NSStringからnull文字を削除

デバイス制御系の処理を書いているとデバイスのSDKから渡されたNSStringが変な動きをすることがあります。
stringWithFormatで処理しても途中で切れたり、NSMutableStringのappend系で追加しても切れたり。。
私が直面した現象の原因は単純で、null文字がNSStringに含まれていることなんですけど、なんつーかFucking!ですよね。

いくら舌打ちしたところでSDKを修正してもらえるわけではありませんので、対応を。

下記のコードでNSStringに含まれているnull文字を削除できます。

-(NSString*)trimNullString:(NSString*)val
{
  if (!val) return nil;

  NSInteger length = [val length];
  unichar buffer[length];
  [val getCharacters:buffer range:NSMakeRange(0, length)];

  int endpos = 0;
  for (int i=0; i<length; i++) {
    unichar c = buffer[i];
    if (c == 0x0){
      endpos = i;
      break;
    }
  }

  if (endpos > 0){
    NSString *result = [NSString stringWithCharacters:(const unichar *)buffer  length:(NSUInteger)endpos];
    return result;
  } else {
    return @"";
  }
}

[iOS]Objective-cでBASE64変換 (iOS7以降)

以前こんな投稿をしていたんですけど、先日NSDataのメンバを探していたら ん! ってビックリしました。

どうやらiOS7では正式なBASE64変換がサポートされたようです。
NSDataの
 base64EncodedStringWithOptions
とか
 initWithBase64EncodedString
ね。
リファレンス

さらに、iOS4以上で使える(iOS7ではdeprecatedだけど)base64Encoding とか initWithBase64Encoding と言うメソッドも発見!
こんなのあったっけ??

iOS7.1からAdHoc配信が出来なくなった!

今までフツーに出来ていたアドホック&OTAでのアプリの配布(開発版ね)が急に出来なくなった。

「・・・の証明書が有効ではないため、Appをインストールできません。」

とか。

はぁ?
ちょっと焦りましたけど、どうやらiOS7.1からはHTTPSサイトのみでOTA配信が許されるようです。

配布サイトにSSL証明書(共有SSLでもOK)を設定して無事解決!

[objective-c] subviewのz-orderを入れ替える

UIViewの表示順(z-order とか z-index とか言われるヤツ)を入れ替える方法です。

表示順を変えるだけでしたら

#import “QuartzCore/QuartzCore.h”

subview1.layer.zPosition = 2;
subview2.layer.zPosition = 1;

のようにlayerのzPositionを入れ替えればOKです。
ただし、このzPositionは初期値で0ですので最初から表示順を指定していた場合には有効ですが、
後から変更する場合にはちょっと都合が悪い。

ですので、そういった場合にはsuperviewに追加されているsubviewのindexを直接変更した方が楽チンです。

で、どうするのかと言うと。
まず、入れ替えたいsubviewのindexを取得します。

UIView *superview = subview1.superview;
int idx1 = [superview.subviews indexOfObject:subview1];
int idx2 = [superview.subviews indexOfObject:subview2];

その上で入れ替えます。

[superview exchangeSubviewAtIndex: idx1 withSubviewAtIndex: idx2];

これでsubviewの表示順が入れ替わってくれますよ。

Xcode5でDeploymentTargetを変更する方法

Xcode5で新規プロジェクトを作るとDeploymentTargetが7.0になっています。
まぁそれはいいんですけど、さすがに7.0以降しか対応しないアプリはまだ時期尚早だと思うので、6.0とかに変更したいのですがプルダウンからは7.0しか選択出来ず。。。

どーしたらいいんだよ〜〜〜!
アップルをちょっと憎たらしく思いながら調べてみると、どうやら対象のアーキテクチャにarm64が入っている時は64bit対応の7.0以降しか選択できないようになっているみたいです。

Build SettingsのArchitecturesを「Standard architectures(armv7,armv7s)」に変更すると昔のOSをDeploymentTargetで選択できるようになりました。
よかったよかった。

NSNotificationCenterを使った通知のサンプル

オブジェクト間で通信、通知を行いたい事って頻繁にありますよね。
解決方法として色んなパターンがあると思いますが、cocoaフレームワークには大変便利な通知システムが用意されています。

使い方も簡単で、しかもオブジェクト間の関連性が薄いので大変使いやすいです。

通知は文字列の名称を使って識別されますので、ヘッダに定数を宣言しておきます。
通知を発行するクラスのヘッダに書くのが良いのではないでしょうか?

#define kDataManagerFinishLoading @"DataManagerFinishLoadingMsgKey"

通知を発行する側はこんな感じで簡単に通知できます。

// 引き渡しパラメータの作成
// Dictionaryでなんでも渡せます
NSDictionary* info = @{ 
  @"date":[NSDate date],
  @"title":@"目くじら", 
  @"count":[NSNumber numberWithInt:12] 
};

// 通知                    
[[NSNotificationCenter defaultCenter] 
    postNotificationName:kDataManagerFinishLoading  
    object:self 
    userInfo:info];

通知を受け取る側のコードはこんな感じです

最初に、NSNotificationCenter に通知の監視を登録します。
self.dataManagerってのが通知を発行するオブジェクトで、通知を受け取るオブジェクトがselfだとすると。

[[NSNotificationCenter defaultCenter] 
    addObserver:self 
    selector:@selector(handleDataManagerFinishLoading:) //←通知を受け取るセレクタ
    name:kDataManagerFinishLoading 
    object:self.dataManager];

受け取った時の処理を書いておきます

// kDataManagerFinishLoading の通知ハンドラ
-(void)handleDataManagerFinishLoading:(NSNotification *)aNotification
{
    //userInfoはDictionaryなので色んな情報を受け取る事ができます。   
    NSDate* date = [[aNotification userInfo] objectForKey:@"date"];
    NSString* title = [[aNotification userInfo] objectForKey:@"title"];
    NSNumber* count = [[aNotification userInfo] objectForKey:@"count"];
    
    //なにかしら処理

}

通知が不要になったら解除しておきます。
dealloc等に書いておくのがよいのではないでしょうか?

[[NSNotificationCenter defaultCenter] removeObserver:self];

以上

オブジェクトのプロパティを監視する

あるオブジェクトのプロパティ値が変更された時に何かしら処理を行いたい場合のサンプルです。

対象オブジェクト objA が title というプロパティを持っている場合を例とすると下記のような感じです。

objAにプロパティの変更通知を登録 (objAを生成したタイミングなどで)

// 
[objA addObserver:self forKeyPath:@"title" options:NSKeyValueObservingOptionNew context:NULL];

通知はaddObserverで登録したオブジェクトの下記のメソッドが呼び出されます

// 監視対象が変更された時に呼び出されるコールバック
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object 
			change:(NSDictionary *)change 
		       context:(void *)context
{
    if ([keyPath isEqual:@"title"])
    {
	NSString* newTitle = [[change objectForKey:NSKeyValueChangeNewKey] stringValue];
        NSLog(@"newTitle :%@", newTitle);

        //変更された時の処理を書く

    }
}

通知が不要となったら登録を解除 (objAを廃棄する前に実行します)

//
[objA removeObserver:self forKeyPath:@"title" context:NULL];

cocoaフレームワーク、、便利過ぎる。。