Swiftで遊ぼう! on Hatena

あしたさぬきblogでやってた初心者オヤジのiOSプログラミング奮闘記がHatenaに来ました

Swiftで遊ぼう! - 293 - UIImageとUIImageView

2016年11月20日:Swift 3に改訂*1

f:id:yataiblue:20151231144735j:plain UIImageとUIImageViewの実装

スタンフォード大学のポール先生のiOS開発講座に準拠した内容で*2、内容は画像の取り扱いです。

新しいプロジェクトを作ります。Single View Applicationを選んで、名前を「Cassini」にします。この名前の意味という名前の由来を知らなかったので勉強になりました。

まずプロジェクト・ナビゲータにあるViewController.swiftを消去します。次に、Main.storyboardを選択して、画面に表示されている既存のViewControllerも消去して、新しいViewControllerをオブジェクト・ライブラリから「ドラッグ&ドロップ」します。

メニューからFile > New > File...を選び、iOSのSourceからCocoa Touch Classを選び、SubclasssをUIViewControllerにして、Class名をImageViewControllerに変更してプロジェクト内にセーブします。

Main.storyboardにある新しいViewControllerを選択して、アイデンティティ・インスペクタのClassをImageViewControllerに替えます。

ImageViewControllerファイルにデフォルトで用意されているライフサイクルに関するメソッド、viewDidLoad()以外*3は、消してしまします。

class ImageViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

}

ユーザーインターフェイスに関するオブジェクトの準備は、オブジェクト・ライブラリからMain.storyboardにドラッグ&ドロップするのが一般的です。しかし、コンピューターサイエンスを専攻する学生なら、コードからオブジェクトを作る方法を理解する必要があるためポール先生(スタンフォード大学)はコードによる実装のデモをします。

画像の扱い

画面で画像を表示させるために2つのオブジェクトが必要になります。画像そのものはUIImageクラスで、画面上に表示させるコンテナ・オブジェクトがUIImageViewクラスです。UIImageだけあってもUIImageViewが無いと画面に表示されないということです。

ストーリーボードからオブジェクトを作成する一般的な方法

オブジェクト・ライブラリからUIImageViewを選択して、Main.storyboard上のImageViewControllerにあるView*4にドラッグ&ドロップします。この時点でジェネリックなImageViewオブジェクトが生成されます。

ジェネリックなオブジェクトを扱うためにコードに繋ぐ作業が必要です。「Ctrl + ドラッグ」してImageViewControllerのコード内に繋げてやれば@IBOutletのimageViewインスタンスができます。-> Swiftで遊ぼう! - 544 - 「Swiftで遊ぼう! - 142 - Xcode6でConnection」のXcode7バージョン - Swiftで遊ぼう! on Hatena

class ImageViewController: UIViewController {

    @IBOutlet weak var imageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

}

この@IBOultletのインスタンスは、このままでViewControllerのsuperviewであるViewのsubviewになっています。

コードを使ってUIImageViewオブジェクトを実装する方法を説明します。

class ImageViewController: UIViewController {
    
    private var imageView = UIImageView()

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }
   
}

@IBOutletの場合と異なり、UIImageViewのインスタンスを作成するだけじゃ、ImageViewControllerにあるViewに設置されません。ストーリーボードで実行した「Ctrl + ドラッグ」、視覚的に繋げる方法は、コード実装の場合使えません。コードを使う場合、すべてコードで書かないといけないんです。

それでも、話は単純で、ViewにUIImageViewを加えるメソッド、「addSubview()」を書くだけです。

view.addSubview(imageView)

しかし、このメソッドを何処に書き込むのか? これを理解するためにViewControllerのライフサイクルを考えてやらなければなりません-> Swiftで遊ぼう! - 294 - viewDidLoad、viewWillAppear、viewWillDisappearのタイミング - Swiftで遊ぼう! on Hatena Viewが画面にロードされるタイミングなので、viewDidLoad()メソッド内に加えるのがベストです。「super.viewDIdLoad()」を書く理由は、Swiftで遊ぼう! - 407 - Initializer イニシャライザ - Swiftで遊ぼう! on Hatenaにあります。

override func viewDidLoad() {
     super.viewDidLoad()
     view.addSubview(imageView)
}

イメージを受け入れるコンテナ(UIImageView)は用意ができたので、次はイメージ(UIImage)です。イメージをは変更が加わっても対応できるように計算型プロパティにします。

private var image: UIImage? {
    get {
        return imageView.image
    }
    set {
        imageView.image = newValue
        imageView.sizeToFit()
    }
}

sizeToFit()メソッドは、新しいイメージを習得すると、そのサイズに合わせimageViewのサイズを合わせる便利なメソッドです。

imageインスタンス生成時にイメージは存在しないのでオプショナル型にします。

インターネットからイメージをダウンロードするため、画像の情報を扱うMVCのモデル(M)を用意します*5

メニューからFile > New > File...を選び、iOSのSourceからSwift Fileを選び、DemoURL.swiftファイルを用意して、画像データのアドレス情報を集約するグローバル定数を作ります。

import Foundation

struct DemoURL
{
    // 元々あった画像は既に無くなったので他の画像に変更(2016年11月20日)    
   static let Stanford = 
       "https://upload.wikimedia.org/wikipedia/
            commons/c/cd/Stanford_Oval_May_2011_panorama.jpg"

    static let NASA = 
    [
        "Cassini" : "http://www.jpl.nasa.gov/images/cassini/
                      20090202/pia03883-full.jpg",
        "Earth" : "http://www.nasa.gov/sites/default/
                     files/wave_earth_mosaic_3.jpg",
        "Saturn" : "http://www.nasa.gov/sites/default/
                       files/saturn_collage.jpg"
    ]
    
    static func NASAImageNamed(imageName: String?) -> URL? {
        if let urlString = NASA[imageName ?? ""] {
            return URL(string: urlString)
        } else {
            return nil
        }
    }
}

URLクラスのimageURLがモデル(M)になります。imageURLに新しい画像アドレス情報(URL)がセットされる度にイメージをクリアして、アドレス情報から画像をダウンロードするプロパティ・オブザーバは便利です。

var imageURL: NSURL? {
    didSet {
        image = nil
        fetchImage()
    }
}

fetchImage()はカスタムメソッドなので実装する必要があります。

private func fetchImage() {
    guard let url = imageURL else {
        return
    }
    var imageData: Data?
    do {imageData = try Data(contentsOf: url)
    } catch {
        print("fail to download")
    }
    image = UIImage(data: imageData!)
}

このメソッドが呼ばれるタイミングは、imageURLにアドレス情報がセットされた時*6です。ということはimageURLに情報が保持されているはずですがオプショナル型なので、[guard」を使ってアンラップします。さらにurl情報を使って、インターーネットからイメージのデータを新しいDataクラスのイニシャライザで受け取りますが、throws付きのイニシャライザなのでtryしてdo-catchする必要があります*7。そしてDataクラスのパラメータを使ってUIImageインスタンスを生成しました。

imageURLに値が読み込まれるタイミングは、Viewが表示されるタイミングでいいのでviewDIdLoad()に加えます。

override func viewDidLoad() {
    super.viewDidLoad()
    view.addSubview(imageView)
    imageURL = URL(string: DemoURL.Stanford)
}

imageURLにアドレス情報を指定してやると、プロパティ・オブザーバーが働いて自動的にimageがダウンロードされimageViewにセットされます。

ラン(Cmd + R)するとイメージがダウンロードされて画面に...

ぶ・ぶ・ぶ・ぶー! iOS9以降(iOS10)の開発なら画面は真っ白なままです!

App Transport Securityを理解しないとダウンロードできません! iOS9からセキュリティが強化されたからです。

yataiblue.hatenablog.com

ちゃんとATSを設定してやると...

f:id:yataiblue:20161120171821j:plain

指定したイメージが超巨大なため、空の一部しか表示されなかったからです。スクロールもできないし拡大縮小もできないんで、これがどんなイメージかさっぱり分かりません。ということで、これを実現するためにScroll Viewを実装してみます。

簡単に実装する方法、ストーリーボードからScroll ViewをMain.storyboardにあるView*8にドラッグ&ドロップします。Viewと同じサイズに広げてからコンストレイントも設定します*9

ストーリーボードに設置したオブジェクトをコードに繋げる方法は「Ctrl + ドラッグ」です。ImageViewControllerに繋ぐと@IBOutlet付きのプロパティが作られます。

@IBOutlet weak var scrollView: UIScrollView!

次にimageViewをViewの下位層にあたるscrollViewに加える必要があるので、viewDidLoad()メソッドを次のように変更します。

override func viewDidLoad() {
    super.viewDidLoad()
    // view.addSubview(imageView)からscrollViewに切り替え
    scrollView.addSubview(imageView)
    imageURL = URL(string: DemoURL.Stanford)
}

これだけでも不十分です。scrollViewの大きさが設定されていません。サイズは(0, 0)です。ということでcontentSizeをimageViewのフレームサイズと同じにします*10

imageがimageViewにセットされた時にscrollViewのcontentサイズを設定すればいいので、次のように計算プロパティで設定します。

private var image: UIImage? {
    get {
        return imageView.image
    }
    set {
        imageView.image = newValue
        imageView.sizeToFit()
        scrollView?.contentSize = imageView.frame.size
    }
}

ここで重要なポイントがあります。scrollViewが@IBOutletプロパティだということです。どのタイミングでインスタンスが作成されるか分からないため「?」をつけていないと、タイミングによってエラーになる可能性があります*11。@IBOutletプロパティは、どちらにしろオプショナル型なので「?」をつけていても問題無くコンパイルできます。そして、タイミングがズレたときの対処として@IBOutletプロパティ宣言次にプロパティ・オブザーバーで同様の設定をします。

@IBOutlet weak var scrollView: UIScrollView! {
    didSet {
        scrollView?.contentSize = imageView.frame.size
    }
}

ここでランをしてみます。するとスクロールができるようになりました。

f:id:yataiblue:20161120191731j:plain

スクロールはできるようになりましたが、拡大とか縮小はできません。これを実現するためにProtocolに準拠する必要があるんです。

fetchImage()メソッドを呼ぶタイミングの考察

ここで説明しているfetchImage()メソッドは、巨大なファイルをインターネットから引っぱってくる作業です。時間やメモリーを大量消費する高価な作業のため、無駄に呼びたくないんです。ちゃんと考えて実行する必要があります。

まずfetchImage()メソッドは、モデルのimageURLの値がセットされる度に、imageが無ければ必ず呼ばれます。しかし、imageが無くても、表示するViewが無かったらどうでしょう? Viewの表示有る無しにかかわらず、imageが無いからfetchImage()を呼ぶという仕組みは、リソースの浪費です。少なくともViewが表示された時にfetchImage()が実行されるようにした方がいいでしょう。Viewの表示があるかどうかどうやって判断するのか? windowプロパティを使うのが常套手段です。iOS開発でwindowプロパティを扱うことはありませんが、windowが「nil」か「not nil」でViewの表示状態を判断することができます。したがって、imageURLのプロパティ宣言は次のようにwindowが「nil」でなかった時だけダウンロードするようにします。

var imageURL: URL? {
    didSet {
        image = nil
        if view.window != nil {
            fetchImage()
        }
    }
}

もう一つ、Viewが表示されていなかったけど、再びViewが表示さえるタイミングでfetchImage()を呼ぶ必要があります。ViewControllerライフサイクルを考えると次のコードを加えなければなりません。

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    if image == nil {
        fetchImage()
    }
}

これで少しばかりパスオーマンスが改善しました。

画像の取り扱いを理解することが目的だったので、fetchImage()でかなり時間が取られ無反応な状態への対処法や画像の拡大縮小への対応はできていません。それは次回に持ち越しです。

*1:2016年11月19日:Swift 3に対応開始、2016年6月4日:Developing iOS 9 Apps with Swift - Free Course by Stanford on iTunes Uに合わせてiOS8向けの記事をiOS9に変更、2016年6月6日:fetchImage()のタイミングに関する説明を加えます。

*2:iOS9向けの講義ではレクチャー7の「Closures, Extensions, Protocols, Delegation, and ScrollView」にある内容と同じです。

*3:didReceiveMemoryWarning()とprepare(for segue: UIStoryboardSegue, sender: Any?)はコメントアウトしています。

*4:ViewControllerをドラッグ&ドロップすると、superviewにあたるViewがデフォルトで付いてきています->Swiftで遊ぼう! - 248 - UIViewの初期化ステップ - Swiftで遊ぼう! on Hatena

*5:MVCの理解は、iOS開発に必須なので理解してください->Swiftで遊ぼう! - 259 - HappinessViewControllerでコントール MVC - Swiftで遊ぼう! on Hatena

*6:この後のviewDidLoad()メソッドで、要するにViewが表示されるタイミングです。

*7:エラー・ハンドリングはiOS9から利用しています-> Swiftで遊ぼう! - 451 - Swift 2 : Error Handling(エラー・ハンドリング) - Swiftで遊ぼう! on Hatena

*8:コードでimageViewを実装しているためストーリーボードにImage Viewはありません。

*9:コンストレイントの設定の仕方は、Swiftで遊ぼう! - 204 - フィリングを使ってレイアウト調整(Auto Layoutのまとめ) - Swiftで遊ぼう! on Hatenaで説明しています

*10:Swiftで遊ぼう! - 249 - UIViewの座標システム: iOS10 - Swiftで遊ぼう! on Hatenaで確認しましょう。

*11:「?」が無くてもランはできます。