Swift+XcodeでiOSアプリを作ってみた(導入編)

/web/iphone

2月中旬からiPhoneアプリ作ってました。 アプリ自体が初めてなので右も左も判りませんでしたが、SwiftはJavaSciptに似てるので取っつきやすかったと思います。 良くあるTableView + WebViewのニュース系アプリですが、1人で実装して3月末には公開申請出せたんで、まあ入りやすいんじゃないですかね(申請通ったとは言ってない)。

  1. UIパーツの配置はStoryBoardで行う
  2. JSONを取得してTableViewCellに流し入れる
  3. データの保存にCoreDataを使用する
  4. TableViewCellからWebViewに遷移する
  5. NavigationBarとTabBarを共存させる
  6. 通信中を示すインディケーターと、通信失敗時のエラー表示
  7. 無限スクロールによる逐次読込と、Pull to Refresh

だいたいこんな感じのことをやった。書いてる途中でSwift2.2になったりというスパイスもありましたが。 ライブラリ使ってないのでCocoaPodsなんかは追々調べなければなりませんが、一通りは作れて慣れたと思います。 まだSwiftでググってもObjective-Cの情報ばっかだったりしますが、まあclassやメソッド名は共通してますし、Objective-C情報からとっかかりにするのも難しくはないかなと。

Note: 文量が多すぎたので分割しました。応用編もヨロシク!

目次

  1. StoryBoard (Auto Layout)
  2. HTTP通信を許可する
  3. JSONを取得する
  4. Web Viewに表示する
  5. Web Viewにインディケーターを表示する
  6. Web Viewにブラウザ機能を追加する
  7. 画像の非同期取得
  8. 日付(NSDate型)を変換するサンプル
  9. UINavigationControllerを使う
  10. NavigationBarの色を変える
  11. UITabBarControllerを使う
  12. TabBarの色を変える
  13. ローカルにデータを保存
  14. unexpectedly found nil while unwrapping an Optional value

StoryBoard (Auto Layout)

UIパーツの配置と画面遷移にはStoryBoardを使いました。 最初はなかなか思う通りに設置できなくて、実機でズレたりしましたけど、幾つかコツを掴めば直感的で判りやすいと思います。 Swiftコードで配置する方法もありますが、表示はStoryBoardでなるべく行った方が、Swiftファイルではロジックに集中できるのでクールだと思います。

可変サイズに対応する

Pinメニューにて上左右の制約を設定している様子

上下左右のmarginの制約(Constraint)を適切に設定することが肝要。 だいたいは画像のように、←↑→の制約をセットで入れておくことになります。WebViewを画面いっぱいにするときは↓も追加する。 隣接するオブジェクトからの余白を固定する制約となりますので、対象オブジェクトがViewControllerの端にあった場合、表示デバイスの画面サイズに応じて伸び縮みすることになります。

marginの数字は現状の値で既に入っているので、殆ど変更する機会は無いです。 数値がマイナスになっているのは、「Constraint to margin」にチェックが入っているから。理由がキニナル人はApple Docsとか見て俺にも教えてください。

配置と制約の不一致で黄色警告が表示されている様子

オブジェクトの配置が制約と違っていると、画像のように黄色い警告が出ます。 制約を設定した後で、手が滑って矢印キー等でずれてしまったとか。制約が足りないとか、重複していたりとか。

中央寄せにする

AlignメニューでHorizontallyとVerticallyを設定している様子

オブジェクトを中央寄せしたい場合、width(とheight)を設定した上で、 AlignメニューのHorizontally(Vertically) in Containerにチェックを入れる。

Labelに行を幾らでも使えるようにする

Attributes InspectorでLineの値を0にしている様子

Label内のテキストはデフォルトで切り詰められますが、内容に応じて伸長させたい場合は、 Linesを0に設定する(初期値は1)。

HTTP通信を許可する

iOS9からWebViewなどで、HTTPアクセスが制限されています。 なので外部へ通信しようとすると、以下のようなエラーが出る。

App Transport Security has blocked a cleartext HTTP (http://) resource load since it is insecure. Temporary exceptions can be configured via your app’s Info.plist file.

「例外はInfo.plistを参照する」みたいに書いてあるので、そうする。 XcodeでInfo.plistを開くときに、「Open As → Souce Code」すると、XMLぽい文書が見れる。これだと判りやすい

方法1. App Transport Security(ATS) でこの制限を全体的に許可してあげる。無制限のWeb利用はレーティングの設定対象となるので注意。

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

方法2. ドメインを登録し、HTTPアクセスできるサイトを設定する

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>insecure.example.com</key>
        <dict>
            <key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
            <true/>
        </dict>
    </dict>
</dict>

方法2. ドメインを登録し、HTTPアクセスできるサイトを設定する

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>insecure.example.com</key>
        <dict>
            <key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
            <true/>
        </dict>
    </dict>
</dict>

Info.plist 用のキー

* NSAppTransportSecurity (Dictionary)
    * NSExceptionDomains (Dictionary)
        * NSAllowsArbitraryLoads (Bool)
        * <domain-name-for-exception-as-string> (Dictionary)
            * NSExceptionMinimumTLSVersion (String)
            * NSExceptionRequiresForwardSecrecy (Bool)
            * NSExceptionAllowsInsecureHTTPLoads (Bool)
            * NSRequiresCertificateTransparency (Bool)
            * NSIncludesSubdomains (Bool)
            * NSThirdPartyExceptionMinimumTLSVersion (String)
            * NSThirdPartyExceptionRequiresForwardSecrecy (Bool)
            * NSThirdPartyExceptionAllowsInsecureHTTPLoads (Bool)

JSONを取得する

let url = NSURL(string: "http://example.com/json/")!

let task = NSURLSession.sharedSession().dataTaskWithURL(url) { (data, response, error) -> Void in
    if error != nil { print(error) }
    else {
        //print(NSString(data: data!, encoding: NSUTF8StringEncoding))
        do {
            // json to NSDictionary
            let jsonResult = try NSJSONSerialization.JSONObjectWithData(data!, options: NSJSONReadingOptions.MutableContainers) as! NSDictionary

            // access data
            if jsonResult.count > 0 {
                if let items = jsonResult["Items"] as? NSArray {
                    for item in items {
                        if let title = item["title"] as? String {
                            let update = item["update"] as! String
                        }
                    }
                } //if let items = jsonResult["Items"] as? NSArray {
            } // if jsonResult.count > 0 {
        } catch { print("JSON serialization Failed!") }
    }
}
task.resume() // タスク実行処理。よく忘れる

↑が一連の処理だけど、dataTaskWithURL()の処理が長いと、コメントにあるように唐突に表れるtask.resume()で混乱しやすい。ので、処理を分ける。

func executeTaskURL() {
    let url = NSURL(string: "http://example.com/json/")!

    // main thread で処理を行う
    let session = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
    let task = session.dataTaskWithURL(url, completionHandler: loadJSON)
    task.resume()
}

func loadJSON(data: NSData?, response: NSURLResponse?, error: NSError?) {
    if error != nil { print(error) }
    else {
        //print(NSString(data: data!, encoding: NSUTF8StringEncoding))
        do {
            // json to NSDictionary
            let jsonResult = try NSJSONSerialization.JSONObjectWithData(data!, options: NSJSONReadingOptions.MutableContainers) as! NSDictionary

            // access data
            if jsonResult.count > 0 {
                if let items = jsonResult["Items"] as? NSArray {
                    for item in items {
                        if let title = item["title"] as? String {
                            let update = item["update"] as! String
                        }
                    }
                } //if let items = jsonResult["Items"] as? NSArray {
            } // if jsonResult.count > 0 {
        } catch { print("JSON serialization Failed!") }
    }
}

ついでにメインスレッドで処理を強制するようにした。ユーザーの目に見える表示変更はメインスレッドで行え、という不文律があるため。

Web Viewに表示する

@IBOutlet weak var webView: UIWebView!

override func viewDidLoad() {
    super.viewDidLoad()
    webView.delegate = self
    self.configureView()
}

func configureView() {
    let path = "http://example.com/"
    if let postWebview = self.webView {
        let requestURL = NSURL(string: path)
        postWebview.loadRequest(NSURLRequest(URL: requestURL!))

        //let HTMLString:String! = "<h1>Hello Swift!</h1>"
        //postWebview.loadHTMLString(HTMLString, baseURL: nil)
    }
}
  1. StoryBoardからWebViewをctrl+ドラッグして、レファレンス(Outlet)を貼っておく。
  2. まずviewDidLoadが実行される。webViewのdelegate先を設定しておく。
  3. Web Viewが読み込まれる前に実行されてクラッシュすることもあるらしいので、Web Viewの存在チェックをしておく。
  4. WebViewに表示させる
    • 任意のURLからWebページを表示させたい→loadRequest()
    • 任意のHTMLをwebViewで表示させる→loadHTMLString()

Web Viewにインディケーターを表示する

通信中であることを示すインディケーター(Now Loading...なぐるぐる画像)を設置したい場合は、Activity Indicator Viewを使うとやりやすい。 StoryBoardで適当な位置に固定して、AnimatingとhidesWhenStoppedの設定を有効にしておけば、Animationが止まったときに表示も消えるようになる。 class定義にUIWebViewDelegateを追加するのを忘れないこと。

class DetailViewController: UIViewController,UIWebViewDelegate {
    @IBOutlet weak var webView: UIWebView!
    @IBOutlet weak var indicator: UIActivityIndicatorView!

    override func viewDidLoad() {
        super.viewDidLoad()
        indicator.hidesWhenStopped = true
        webView.delegate = self

        self.configureView()
    }

    func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
        indicator.startAnimating()
        return true
    }
    
    func webViewDidFinishLoad(webView: UIWebView) {
        indicator.stopAnimating()
    }
    
    func webViewDidStartLoad(webView: UIWebView) {
        indicator.startAnimating()
    }
}

Web Viewにブラウザ機能を追加する

WebViewでWebサイトを表示させる場合、サイトのリンクを踏めばページ遷移することになります。 WebViewを置いただけでは「戻る」ボタンも何もないので元のページに戻ってこれません。 なので基本的なブラウザ機能は用意しておく必要があります。

// 前へ
@IBAction func backPage(sender: UIBarButtonItem) {
    self.webView.goBack()
}

// 次へ
@IBAction func nextPage(sender: UIBarButtonItem) {
    self.webView.goForward()
}

// リロード機能
@IBAction func reloadPage(sender: UIBarButtonItem) {
    self.webView.reload()
}

ボタンはtoolBarかなんかで作っておくものとします。必要なメソッドはだいたいwevViewクラスに生えてますが、URLをSNS等に飛ばすシェア機能だけは、ちょいとコード長め。

// シェア機能
@IBAction func sharePage(sender: UIBarButtonItem) {
    guard let postWebview = self.webView else { return }
    guard let url = postWebview.request?.URL else { return }

    let urlString = NSString(string: String(url))
    let activityVC = UIActivityViewController(activityItems: [urlString], applicationActivities: nil)

    self.presentViewController(activityVC, animated: true, completion: nil)
}

指定したオブジェクトに応じて、ファイル共有やメール添付などの機能を自動的に表示してくれるUIActivityViewControllerを使う。activityItemsはArrayで渡す形なので追加できる。除外したいActivityの指定とかも可能。

画像の非同期取得

tableCellにコンテンツを流し込む場合とか、同期処理で取得すると、各セルの描画が画像のロード完了まで待たされて、すげー重くなるので非同期処理を書く。

// set icon-image
let imageView = cell.viewWithTag(1) as! UIImageView
let imgPath = object.valueForKey("img")!.description as String

if imgPath.isEmpty {
    imageView.image = UIImage(named: "no_img.png")
}
else {
    let url = NSURL(string: imgPath)
    let requestUrl = NSURLRequest(URL: url!)
    NSURLConnection.sendAsynchronousRequest(requestUrl, queue: NSOperationQueue.mainQueue()) { (response, data, error) -> Void in
        
        if error != nil {
            imageView.image = UIImage(named: "no_img.png")
        }
        else {
            if let dlImage = UIImage(data: data!) {
                imageView.image = dlImage
            }
        }
    }
}

TableViewCellにimageViewを置いて、Tag=1 を設定しています。object.valueForKey("img")に画像URLが入っているとします。画像URLがなければ、No Image画像を表示させます。メインUIの変更なのでメインスレッドで処理しますが、sendAsynchronousRequestはiOS9で非推奨になったので、dataTaskWithURL()で書き直しました。

let url = NSURL(string: imgPath)
let session = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
let task = session.dataTaskWithURL(url!, completionHandler: { (data, responce, error) in
    if error != nil {
        imageView.image = UIImage(named: "no_img.png")
    }
    else {
        if let dlImage = UIImage(data: data!) {
            imageView.image = dlImage
        }
    }
})
task.resume()

日付(NSDate型)を変換するサンプル

日付でソートさせたいだけならエポック秒で吐かせるとか、単純に20160405201912みたく数字だけにしちゃっても良いのですけど、Labelに色んな表示を出し分けたり曜日を計算したりとかしたいならNSDate型に変換しておくと便利です。

// 文字列からNSDate型に変換する
let update = item["update"] as! String
let update_at: NSDate = self.getDateFromString(update, format: "yyyy-MM-dd HH:mm:ss")
// NSDate型としてCoreDataで保存する
newPost.setValue(update_at, forKey: "update_at")

func getDateFromString(date: String, format: String) -> NSDate {
    let dateFormatter = NSDateFormatter()
    dateFormatter.locale = NSLocale(localeIdentifier: "ja_JP")
    dateFormatter.dateFormat = format

    return dateFormatter.dateFromString(date)!
}

NSDateから任意のStringに変換する場合は、以下のように。formatに()が含まれていたら曜日を挿入する、みたいな関数を作ります。

// NSDate型から文字列を作成する
let update_at = object.valueForKey("update_at")! as! NSDate
let update_date = getStringFromDate(update_at, format: "MM/dd() HH:mm")
let update_cell = cell.viewWithTag(3) as! UILabel
update_cell.text = update_date
    
func getStringFromDate(date: NSDate, format: String) -> String {
    let dateFormatter = NSDateFormatter()
    dateFormatter.locale = NSLocale(localeIdentifier: "ja_JP")
    dateFormatter.dateFormat = format
    let dayOfTheWeek = getDayOfTheWeek(date)
    var dateString = dateFormatter.stringFromDate(date)
    dateString = dateString.stringByReplacingOccurrencesOfString("()", withString: "(" + dayOfTheWeek + ")")

    return dateString
}

// day of the week
func getDayOfTheWeek(date: NSDate) -> String {
    let dateFormatter = NSDateFormatter()
    dateFormatter.locale = NSLocale(localeIdentifier: "ja_JP")

    let cal: NSCalendar = NSCalendar(identifier: NSCalendarIdentifierGregorian)!
    let comp: NSDateComponents = cal.components(
        [NSCalendarUnit.Weekday],
        fromDate: date
    )
    let weekdaySymbolIndex: Int = comp.weekday - 1
    let dayOfTheWeek = dateFormatter.shortWeekdaySymbols[weekdaySymbolIndex] // 日
    //let dayOfTheWeek = dateFormatter.weekdaySymbols[weekdaySymbolIndex] // 日曜日

    return dayOfTheWeek
}

UINavigationControllerを使う

NavigationController
    |- ViewController (master)
        |- ViewController (detail)

NavigationControllerやViewControllerの遷移は、Segue(セグエ)を用いて接続する。 Segueは主から従へと、Ctrl+ドラッグすることで追加できる。 Segueには幾つか種類があり、だいたいはShowセグエを使うが、Master/Detail Applicationの時はShow Detailを使う。 Show Detail は主にiPad用で、ViewControllerの差し換えを行う。左ペインを維持しつつ右ペインの表示を切り替えるなど。

Show (Push)
画面はアニメーションしながら次のViewControllerを表示します。
Show Detail (Replace)
主にiPadで現在表示しているViewControllerの差し替えを行います。
Present Modally (Modal)
ViewController)上に新しいViewControllerを表示します。新しい画面はNaivationControllerでは管理されません。
Present As Popover (Popover)
主にiPadで吹き出しスタイルの表示を行います。

Note: ()内はXcode5までの表記。Xcode6でSegue名が変更されている。

tableViewControllerの場合、以下のようにして接続先のViewControllerに値を渡す。

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    if segue.identifier == "show" {
        if let indexPath = self.tableView.indexPathForSelectedRow {
            let object = self.fetchedResultsController.objectAtIndexPath(indexPath)
            let controller = segue.destinationViewController as! DetailViewController
            controller.detailItem = object
        }
    }
}

NavigationBarの色を変える

AppDelegateかUINaivationControllerのサブクラスにおいて、以下のように設定する。


// 色指定
let cl_magenta = UIColor(red: 179.0/255, green: 0.0/255, blue: 134.0/255, alpha: 1.0)

// ナビゲーションバーの色指定
UINavigationBar.appearance().barTintColor = cl_magenta
// ボタンのベースの色(設定アイコンの色など)
UINavigationBar.appearance().tintColor = UIColor.whiteColor()
// タイトル文字色
UINavigationBar.appearance().titleTextAttributes = [NSForegroundColorAttributeName: UIColor.whiteColor()]
// 背景色
UINavigationBar.appearance().backgroundColor = UIColor.whiteColor()

背景色に合わせてStatusBarの文字色を変えるには、Info.plistに以下の設定を追加する。

<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleLightContent</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>

以前はUIApplication.sharedApplication().setStatusBarStyle()みたいな設定を使われていたけど、iOS9でdeprecatedになった。

UITabBarControllerを使う

TabBarコントローラーを使うには、StoryBoad上では以下のように接続する。

TabBarController
    |- ViewController_1
    |- Viewcontroller_2
    |- Viewcontroller_3

TabBarControllerをルートとして、接続したViewControllerの数だけTabBarItemが生成される。TabBarItemの設定は、親のTabBarControllerではなく、子のViewControllerで行うことに注意。

TabBarController
    |- NavigationController - ViewController_1
    |- NavigationController - Viewcontroller_2
    |- NavigationController - Viewcontroller_3

NavigationBarとTabBarを併用するには、上記のようにTabBarControllerをルートとしてNavigationControllerを生やす形を取る。

The view controller that resides at the bottom of the navigation stack. This object cannot be an instance of the UITabBarController class.

- init(rootViewController:), UINavigationController Class Reference

UINavigationController Class Referenceでは「NavigationControllerの下には、TabBarControllerは置けない」みたいなことが書いてある。 実際にStoryBoardで置こうとするとできるが、その場合は全てのタブでtitleを共有することになったり、細々とした実装が難しくなる。 NavigationBarをルートとしたほうがControllerの数が減ってスッキリするんですが、実装としては各タブにそれぞれNavigationControllerが生えてるほうがシンプル。

参考: How to set title of Navigation Bar in Swift?, Stack Overflow

TabBarの色を変える

AppDelegateかUITabBarControllerのサブクラスにおいて、以下のように設定する。

// タブバーの色指定
let cl_magenta = UIColor(red: 179.0/255, green: 0.0/255, blue: 134.0/255, alpha: 1.0)
let cl_beige = UIColor(red: 249.0/255, green: 218.0/255, blue: 219.0/255, alpha: 1.0)

// バーの背景色
UITabBar.appearance().barTintColor = cl_magenta

// 非選択時の文字色
let nomalAttributes = [NSFontAttributeName: UIFont.systemFontOfSize(10), NSForegroundColorAttributeName: cl_beige]
UITabBarItem.appearance().setTitleTextAttributes(nomalAttributes, forState: UIControlState.Normal)

// 選択時の文字色
let selectedAttributes = [NSForegroundColorAttributeName: UIColor.whiteColor()]
UITabBarItem.appearance().setTitleTextAttributes(selectedAttributes, forState: UIControlState.Selected)

// 選択時のアイコン色
UITabBar.appearance().tintColor = UIColor.whiteColor()

ローカルにデータを保存

ユーザーの設定情報などちょっとしたデータを保存しておきたい場合などには、NSUserDefaultsが便利。

var toDoList = [String]()
toDoList.append(item.text!)
toDoList.count
toDoList.removeAtIndex(indexPath.row)

// 保存
NSUserDefaults.standardUserDefaults().setObject(toDoList, forKey: "toDoList")

// 取得
if NSUserDefaults.standardUserDefaults().objectForKey("toDoList") != nil {
    toDoList = NSUserDefaults.standardUserDefaults().objectForKey("toDoList") as! [String]
}

これだけで利用できる。とはいえメモリに展開されるため、あんまり大きなファイルを突っ込むと起動時にメモリを圧迫する。また複雑なFetchができないので、単純なフィルタ以上のことがしたければ、CoreDataかRealmなんかを利用した方が良い。

unexpectedly found nil while unwrapping an Optional value

たぶんクラッシュ時に最もよく見るエラー文。nilじゃダメなとこにnilが突っ込まれてるよ!ていう警告です。 適当なブレイクポイントを設定して、ステップ実行してやるといいです。 また、左側のペインに、スタックフレーム(クラッシュが発生する地点までにどのようなメソッドが呼ばれたか)が表示されているはずです。 スタックフレームの見方としては、上の方が新しくて、下に行くほど過去にさかのぼることになります。

nil チェック

利用したい変数が有効であるかどうかは、if let...文かguard let...文でチェックできます。

func printMsgOpt(messaage: String?) {
    if let theMessaage = messaage {
        print(theMessaage)
    }
    //print(theMessaage) // 利用できない
}

if let...文では定義した変数はif節の中でしか利用できませんが、guard let...文の場合、アンラップされた変数はguard文を抜けた後でも使用することができます。アンラップとnilチェックを同時に行えるということ。

func printMsg(messaage: String?) {
    guard let theMessage = messaage else { return }
    print(theMessage)
}

if文でのnilチェック(Opitional Binding)は有用ですが、 例えばJSONデータをCore Dataに突っ込む場合など、対象となる値を全てチェックすると凄まじい多重カッコになってしまいます。 Swift2.0で追加されたguard文を使用すると、こうした多重カッコを避けることができます。

do {
    let jsonResult = try NSJSONSerialization.JSONObjectWithData(data!, options: NSJSONReadingOptions.MutableContainers) as! NSDictionary

    if jsonResult.count > 0 {
        if let items = jsonResult["Items"] as? NSArray {
            for item in items {
                guard let title = item["title"] as? String else { return }
                guard let url = item["url"] as? String else { return }
                guard let comrows = item["comrows"] as? Int else { return }
                guard let update = item["update"] as? String else { return }
                guard let created = item["created"] as? String else { return }
                guard let img = item["img"] as? String else { return }
            }
        }
    }
} catch { print("JSON serialization failed!")}

導入編はここまで。応用編もヨロシク!

Note: スパム対策が面倒なので、コメント投稿を廃止しました。以前のコメントは残します。
ご意見・ご要望はtwitter@sigwygかはてブコメントにて。