cockscomblog?

cockscomb on hatena blog

SwiftUIでSFSafariViewControllerを使う手法の探求

SwiftUIからSFSafariViewControllerを使いたい場面は多い。

SafariView

SFSafariViewControllerはビューコントローラーだから、UIViewControllerRepresentableを使ってSwiftUIのビューにしてしまうのが簡単か。

import SwiftUI
import SafariServices

struct SafariView: UIViewControllerRepresentable {
    typealias UIViewControllerType = SFSafariViewController

    typealias Configuration = SFSafariViewController.Configuration

    private let url: URL
    private let configuration: Configuration?

    init(url: URL, configuration: Configuration? = nil) {
        self.url = url
        self.configuration = configuration
    }

    func makeUIViewController(context: Context) -> SFSafariViewController {
        let safariViewController: SFSafariViewController
        if let configuration {
            safariViewController = SFSafariViewController(url: url, configuration: configuration)
        } else {
            safariViewController = SFSafariViewController(url: url)
        }
        return safariViewController
    }

    func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
    }
}

誰でもこういうコードを書いたことがあるんじゃないか。

あとは何らかのきっかけ‌fullScreenCover(isPresented:onDismiss:content:))なんかを使って、このSafariViewを表示してやればいい。 UIViewControllerpresent(_:animated:completion:)とは少し違うけど、他にいい方法を知らない。

じゃあ「何らかのきっかけ」ってなんだろう、というところが本題。

OpenURLAction

SwiftUIでは、TextのイニシャライザにAttributedStringを渡せば、文字列中にリンクを埋め込める。 あるいはそのものずばりLinkビューというのがあって、URLへのリンクを表現できる。 こういうのをクリックしたときもSFSafariViewControllerを表示したい。

実はLinkドキュメンテーションにいい例があった。

Link("Visit Our Site", destination: URL(string: "https://www.example.com")!)
    .environment(\.openURL, OpenURLAction { url in
        print("Open \(url)")
        return .handled
    })

LinkText内のリンクをクリックすると、EnvironmentからOpenURLActionが呼び出される。 そしてこれは上書き可能になっている。 OpenURLActionを上書きして、SFSafariViewControllerを開くようにフックしてやればよいらしい。

渡ってくるURLを状態として持って、URLが存在するときSafariViewを開く、という風にしたい。 何とか再利用性のあるコードにしたいので、ちょっと考える。

ViewModifier

ここで、ViewModifierを使えばモディファイア内に状態を持てることを思い出す。 ちょっと書いてみるとこういう感じになる。

import SwiftUI

struct OpenURLInSafariViewModifier: ViewModifier {
    @State private var url: URL? = nil
    private var isPresented: Binding<Bool> {
        Binding {
            url != nil
        } set: { newValue in
            if newValue == false {
                url = nil
            }
        }
    }

    private let configuration: SafariView.Configuration?

    init(configuration: SafariView.Configuration?) {
        self.configuration = configuration
    }

    func body(content: Content) -> some View {
        content
            .environment(\.openURL, OpenURLAction { url in
                switch url.scheme {
                case "https"?, "http"?:
                    self.url = url
                    return .handled
                default:
                    return .systemAction(url)
                }
            })
            .fullScreenCover(isPresented: isPresented) {
                if let url {
                    SafariView(url: url, configuration: configuration)
                        .edgesIgnoringSafeArea(.all)
                }
            }

    }
}

extension View {
   func openURLInSafariView(configuration: SafariView.Configuration? = nil) -> some View {
        return modifier(OpenURLInSafariViewModifier(configuration: configuration))
    }
}

いっけんよさそう。

SafariServicesUI

こういう感じで使う。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Link("Open in SFSafariViewController", destination: URL(string: "https://developer.apple.com")!)
            .openURLInSafariView()
    }
}
import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Open in SFSafariViewController with [Attributed String](https://developer.apple.com)")
            .openURLInSafariView()
    }
}

状態が隠蔽されていて、シンプルだし、SwiftUIっぽいインターフェースだと思う。

ということでライブラリっぽくSwift Packageにしてみた。

どうでしょうか。

紛らわしい文字列をもっと紛らわしくする

同僚にid:yashigani_wヤシガニ)とid:yigarashi(ワイイガラシ)がいる。いかにも似ているが、あるとき一緒に仕事をすることになって、紛らわしさが限界を超えた。

yashigani
yigarashi

そうとわかって見たら、なるほど「y」で始まって「i」で終わるのが似ているね、くらいに思うかもしれない。しかし不意にSlackやGitHubで見かけると、一瞬どちらかわからない。

この紛らわしさをより俯瞰してみるため、画像にしてみました。

f:id:cockscomb:20200101161439j:plain
Photoshopで作った

どうだ紛らわしいだろう。全体的な形がなんだか似ている。

ところでこの画像はPhotoshopで作った。テキストにガウシアンぼかしをかけて、その後から二値化した。なかなかかっこいいから、もっといろいろ試してみたい。

前置きが長くなったが、テキストにガウシアンぼかしをかけて、その後から二値化 するのを、macOSiOSの仕組みでやってみるのが、本題である。

Core Textでテキストの画像を作る

まずはテキストを画像化しないと始まらない。テキストの画像を作るのは、Core Text(とCore Graphics)で素朴に実装できる。

import CoreGraphics
import CoreText
import Foundation

struct Padding {
    let top: CGFloat
    let left: CGFloat
    let bottom: CGFloat
    let right: CGFloat

    static var zero: Padding {
        return Padding(top: 0, left: 0, bottom: 0, right: 0)
    }
}

func render(text: NSAttributedString, padding: Padding = .zero) -> CGImage {
    let framesetter = CTFramesetterCreateWithAttributedString(text)
    let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(
        framesetter,
        CFRange(),
        nil,
        CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
        nil)

    let width = Int(ceil(frameSize.width + padding.left + padding.right))
    let height = Int(ceil(frameSize.height + padding.top + padding.bottom))

    let ctx = CGContext(
        data: nil,
        width: width,
        height: height,
        bitsPerComponent: 8,
        bytesPerRow: width * 4,
        space: CGColorSpaceCreateDeviceRGB(),
        bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
    guard let context = ctx else {
        fatalError()
    }

    context.saveGState()
    context.setFillColor(.white)
    context.fill(CGRect(origin: CGPoint.zero, size: CGSize(width: width, height: height)))
    context.restoreGState()

    let path = CGPath(
        rect: CGRect(
            origin: CGPoint(
                x: (CGFloat(width) - frameSize.width) / 2,
                y: (CGFloat(height) - frameSize.height) / 2),
            size: frameSize),
        transform: nil)
    let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)
    CTFrameDraw(frame, context)

    guard let image = context.makeImage() else {
        fatalError()
    }
    return image
}

NSAttributedStringからCore TextのAPIを使ってCGImageを作る。このときpaddingを設定できるようにすると、あとで都合がいい。

Core Imageで画像を加工する

画像の加工ではいつものように、Core Imageを使うことを検討する。以前CoreImage.CIFilterBuiltinsで試したように、CIFilter.gaussianBlur()を使えば、ガウシアンぼかしは簡単だ。あとは二値化するだけだが、ここで手が止まる。意外なことに、組み込みのフィルターには二値化が存在しない。

Metal Shading Languageによるカスタムカーネル

二値化は簡単そうなので手作りする。Core ImageカーネルをMetal Shading Languageで書く。ThresholdBinary.metalを以下の内容で作った。またXcodeのBuild Settingsで、Other Metal Compiler Flags-fcikernelを、User-Defined Settingsに追加したMTLLINKER_FLAGSキーに-cikernelを、それぞれ設定する必要がある。

#include <metal_stdlib>
#include <CoreImage/CoreImage.h>
using namespace metal;

extern "C" {
    namespace coreimage {
        float4 threshold_binary(sample_t source, float threshold) {
            float4 image = premultiply(source);
            float y = 0.299 * image.r + 0.587 * image.g + 0.114 * image.b; // BT.601
            float binary = y < threshold ? 0.0 : 1.0;
            return float4(binary, binary, binary, 1.0);
        }
    }
}

RGB値から輝度成分を取り出すために、YUV色空間のYを計算する。これが閾値を超えていれば1、そうでなければ0とすることで、二値化できる。

このカーネルは以下のように読み込める。

import CoreImage

let thresholdBinaryKernel: CIColorKernel = {
    guard
        let url = Bundle.main.url(forResource: "default", withExtension: "metallib"),
        let data = try? Data(contentsOf: url)
    else {
        fatalError("Unable to get metallib")
    }
    guard
        let kernel = try? CIColorKernel(
            functionName: "threshold_binary", fromMetalLibraryData: data)
    else {
        fatalError("Unable to create CIKernel from threshold_binary")
    }
    return kernel
}()

Core Imageによる画像の加工

二値化のカーネルが書けたので、あとは実際に画像を加工するだけだ。

import CoreImage
import CoreImage.CIFilterBuiltins

let ciContext: CIContext = CIContext()
func process(image: CGImage, sigma: Float, threshold: Float) -> CGImage? {
    let ciImage = CIImage(cgImage: image)

    let gaussianBlur = CIFilter.gaussianBlur()
    gaussianBlur.inputImage = ciImage.clampedToExtent()
    gaussianBlur.radius = sigma
    guard let blurredImage = gaussianBlur.outputImage else {
        return nil
    }

    guard
        let processedImage = thresholdBinaryKernel.apply(
            extent: ciImage.extent, arguments: [blurredImage, threshold]
        )
    else {
        return nil
    }

    guard
        let cgImage = ciContext.createCGImage(
            processedImage, from: processedImage.extent)
    else {
        fatalError()
    }
    return cgImage
}

CIImage.clampedToExtent()を使うのがコツで、ガウシアンぼかしをかけるときに画像の端が変な色になってしまうのを防げる。

どうですか

ここまでで、テキストにガウシアンぼかしをかけて、その後から二値化 ができた。iOSでもmacOSでも同じように動くはずだ。

f:id:cockscomb:20200106103459p:plain

なんだかちょっとかっこよくないですか。

参考文献

Metalは初めて使うので、ちょっとだけ勉強しました。

CoreImage.CIFilterBuiltins

iOSmacOSで画像を加工しようとした場合、Core Image frameworkを使うのが簡単だ。画像をハードウェアの支援で高速に処理でき、内蔵された様々なフィルターを使うこともできる。Mac OS X 10.4で初めて搭載されて以来、しっかりとアップデートされ続けている。

2019年のiOS 13やmacOS 10.15では、Core Imageの色々なフィルターをSwiftから扱いやすくなった。

CIFilter APIの変遷

最初期は以下のように、Key-Value Codingを活用したAPIだった。もともとCore Image frameworkは、GUIでユーザーに操作させることを想定しており、文字列などから動的に扱えるように作られている。(あるいは組み込みではないフィルターがインストールされている場合もあり、いずれにしてもKVCは有用である。)反面で、フィルター名やフィルターとパラメータの対応に気を配る必要がある。

import CoreImage

let image: CIImage = ...

guard let filter = CIFilter(name: "CIGaussianBlur") else {
    fatalError()
}
filter.setValue(20, forKey: kCIInputRadiusKey)
filter.setValue(image, forKey: kCIInputImageKey)
guard let processed = filter.value(forKey: kCIOutputImageKey) as? CIImage else {
    fatalError()
}

let context = CIContext()
context.createCGImage(processed, from: processed.extent)

iOS 8のタイミングで、KVCではないAPIも整備され、CIFilter.init(name:parameters:)CIImage.outputImageを使って、以下のように書けるようになった。

guard let filter = CIFilter(name: "CIGaussianBlur", parameters: [
    kCIInputRadiusKey: 20,
    kCIInputImageKey: image,
]) else {
    fatalError()
}
guard let processed = filter.outputImage else {
    fatalError()
}

さらにこれのショートカットで、CIImage.applyingFilter(_:parameters:)メソッドを使い、以下のようにも書ける。見た目はかなりよくなったが、ガウシアンぼかしのために"CIGaussianBlur"フィルターを使って、パラメータとしてkCIInputRadiusKeyを与える、ということは知らなければならない。

let processed = image.applyingFilter("CIGaussianBlur", parameters: [
    kCIInputRadiusKey: 20,
])

iOS 10の頃にはCIImage.applyingGaussianBlur(sigma:)メソッドなどが追加され、いくつかの基本的なフィルターが容易に利用可能になった。

let processed = image.applyingGaussianBlur(sigma: 20)

CIFilterBuiltins

iOS 13やmacOS 10.15から、新たにCIFilterにたくさんのクラスメソッドが追加された。このうちCIFilter.gaussianBlur()を使えば、これまでと同じ処理を以下のように書ける。

import CoreImage
import CoreImage.CIFilterBuiltins

let image: CIImage = ...

let filter = CIFilter.gaussianBlur()
filter.radius = 20
filter.inputImage = image
guard let processed = filter.outputImage else {
    fatalError()
}

let context = CIContext()
context.createCGImage(processed, from: processed.extent)

クラスメソッドのシグネチャclass func gaussianBlur() -> CIFilter & CIGaussianBlurになっているので、型を利用して簡単に扱えるようになっている。このようなクラスメソッドが、組み込みのフィルターそれぞれに用意されている。

import CoreImage.CIFIlterBuiltinsを忘れやすいので注意が必要だ。Core Imageのmodulemapをみると、以下のようになっている。

framework module CoreImage [extern_c] {
  umbrella header "CoreImage.h"
  export *
  module * { export * }
  
  explicit module CIFilterBuiltins {
      header "CIFilterBuiltins.h"
      export *
  }
}

まとめ

Core ImageAPIは徐々に改良が加えられている。

ユースケースに応じて異なるAPIを使うのがよいだろう。単純にガウシアンぼかしを掛けたいのであれば、CIImage.applyingGaussianBlur(sigma:)が最も簡単である。こういったショートカットが用意されていない組み込みのフィルターを利用するなら、CIFilterBuiltinsがよい。組み込みではないフィルターを利用するような場合は、必然的にCIImage.applyingFilter(_:parameters:)メソッドやKVCを使うことになる。

Marzipanを予想した答え合わせ #WWDC19

昨年のWWDCよりさらに前に、Marzipanを予想すると称して、与太話を書いた。その時は「UIKitの移植」と「まったく新しいUIフレームワーク」とそれらの「グラデーション」の3パターンを予想していたが、結果としてはこれらがすべて出た。

Project Catalyst

初めに、Marzipanと呼ばれていたものは、新たに「Project Catalyst」と呼ばれることになった。しかしAppleのドキュメントの中ではこのプロジェクト名ではなく、「UIKit for Mac」と記載されている。これこそ「UIKitの移植」である。

UIKitの移植とともにUIKitを拡張して、コンテキストメニューやメニューバーなど、macOSの基本的な操作に適合できるようにしている。ホバーがUIHoverGestureRecognizerとしてUIGestureRecognizerになっていたり、コンテキストメニューUIContextMenuInteractionとしてUIInteractionになっていたり、抽象度が妙に高いような傾向がみられる。

そのほかにもmacOSに合わせてカスタマイズする方法がいくつも提供されてはいるものの、AppKitでmacOSアプリを作るのと全く遜色ない、とまではいかないように思われる。UIKit for Macは、iOS、特にiPadOSに最適化されたアプリをmacOSへ移植し、クロスプラットフォーム開発を行う目的で利用するのが、自然なユースケースである。

SwiftUI

SwiftネイティブなUIフレームワークがこれほど早くに出てきたことには驚かされた。SwiftUIは昨年の予想における「まったく新しいUIフレームワーク」がまさに具現化したものである。SwiftUIにはView Controllerすらない。

SwiftUIは、最近ではReactに代表され、そしてFlutterやJetpack Composeもまたそうであるように、宣言的なUIフレームワークだ。そしてAPIはSwiftネイティブと呼ぶにふさわしい。Objective-Cの動的な機能がなくてもうまく動くように、CombineというRxめいた新しいフレームワークが様々に機能するようだが、まだ全容を掴みきれていない。

SwiftUIはmacOSiOS、tvOSそしてwatchOSに渡って利用できる。プラットフォームに合わせたUIについても配慮されている。ただしSwiftUIはこの秋にリリースされる最新のOSから対応となるため、すぐにSwiftUIへ移行できる開発者はごく少数だろう。

適応

UIKit for Macのねらいは、MaciPadの距離を近づけることにあるだろう。Macにはアプリが少なく、一方のiPadにはプロユースに耐えうるアプリが足りない。これをまとめて解決するためのクロスプラットフォームである。多くのiOSアプリは、Xcodeプロジェクトでチェックボックスをひとつ有効にするだけで、macOSでも動作するようになる。

反対にSwiftUIは、次世代のUIフレームワークという側面が強い。AppKitも、それよりはモダンなUIKitでさえも、すでに10年以上の歴史を持っている。Objective-CがSwiftになってパラダイムが変化したのに伴い、UIフレームワークの再設計を行なったものだ。少しずつ取り入れやすいように、既存のアプリを少しずつ置き換えられるようになっている。

昨年の「グラデーション」で示したように、いずれも既存のアプリで取り入れやすいように工夫がなされている。


ひととおり振り返ってみたところ、全体的に大意は当たっていたように思う。予想が当たったというより、演繹的に考えれば誰でも同じような考えに辿り着くようなものではある。

この何年か、他のプラットフォームにおける開発環境の改善と比して、Appleが水をあけられつつある、というような印象を抱くことがあった。しかしこの2019年のWWDCでは、そんな懸念を払拭するように、皆に望まれるような改善が行われたことが、何より嬉しい。UIKit for MacもSwiftUIも、ここから何年かかけてさらに磨き上げられていくことを思えば、楽しみが増すばかりである。

Marzipanを予想する

予想というか、根拠のない与太話です。


Marzipanと呼ばれるAppleのプロジェクトは、どのようなものになるのだろう。噂では、iOSmacOSのアプリが一度に作れるようになるらしい。このふたつのOSのアプリを開発する上での主要な差異は、iOSのUIKitとmacOSのAppKitの、UIフレームワークである。その他のフレームワークのほとんどはすでに共通している。この状況を踏まえれば、MarzipanはUIフレームワークであると考えられる。

そのMarzipanがどのようなUIフレームワークになるのか、グラデーションをつけて考えてみる。

UIKitの移植

もっとも単純なのは、UIKitをmacOSへ移植してしまうパターンである。macOSのAppKitは古びてきているから、より近代的なUIKitを持ってきてしまう発想である。

もちろんtvOSのUIKitのように、同じUIKitでも異なるUIを提供することにはなるだろう。それはポインティングデバイスの特性であったり、要求されるUIの複雑さに依るところである。

一方で、ひところプライベートフレームワークとして存在していたUXKitとは異なる実装方法が取られるのではないか。UXKitはAppKitの上にオーバーレイするようなアーキテクチャであったが、そのような方法では不特定多数の開発者に安定的なAPIを提供するのが難しい。

UIKitの移植は、多くの開発者にとっては、iOSアプリを少ないコストでmacOSに移植できるメリットを得られる可能性がある。ただし一定のカスタマイズが必要になる可能性もある。

まったく新しいUIフレームワーク

このタイミングでまったく新しいUIフレームワークの導入が行われる可能性も存在している。それがどのようなものになるのか想像するのは楽しい。

まったく新たなフレームワークが作られるなら、最大の関心事は、それがSwiftネイティブなフレームワークであるかどうかであろう。Objective-Cと決別することで、よりSwiftの言語機能を活かすことができる。静的型付けを活かしたSwiftフレンドリーなフレームワークは、多くの開発者に喜ばれるはずだ。その反面で、これまでObjective-Cの動的な特性を活用してきた部分は失われる。例えばresponder-chainは、まさにそのような機能であり、単純なイベントハンドリングはもちろん、メニューバーやコンテキストメニュー、キーボードショートカットからTouch Barにいたるまで、多くの用途で利用されている。そのようなものが存在しないとすれば、もはや既存のものとはまったく異なるパラダイムのUIフレームワークであるだろう。

昨今ではReactに端を発した、宣言的にビューを構築するUIフレームワークが流行している。宣言的な記述でVirtual DOMを構築し、reconciliationと呼ばれるプロセスによって実際のビューを差分更新する。最近ではGoogleもReactにインスパイアされたFlutterというUIフレームワークを開発している。

Reactの新しいFiberアーキテクチャでは、非同期的にVirtual DOMを構築できる。これによって、一見すると同期的なコードでビューを構築しつつ、実際にはその部分の描画だけを遅らせるようなことが可能になる。これは単にUIのフレームレートを改善するだけに留まらない。時間のかかる処理に依存してビューを更新することが容易になるのはもちろん、アニメーションの表現もしやすくなる。

なんの傍証も存在しない妄言に過ぎないが、例えばReactのようなパラダイムを持ち込んだものがMarzipanであるなら、とてもおもしろい。その一方で、まったく異なるパラダイムフレームワークを導入した場合は、既存のアプリをそう簡単には移植できないため、移行に時間がかかることが懸念される。

グラデーション

UIKitの移植か、完全に新しいUIフレームワークか、二者択一というわけではなく、実際にはグラデーションが存在するだろう。例えばUIKitによく似たAPIで、少し抽象度の高いフレームワークが双方に導入されることも考えられる。あるいはまったく新しいUIフレームワークではあるが、既存のアプリに部分的に取り入れられるようになっている場合もあるだろう。

いずれにせよ、すでに存在しているiOSアプリから少しずつ移行できるようになっていなければ、多くの開発者はなかなかMarzipanを採用できない。AppleにとってMarzipanの開発の目的が、macOSのアプリを充実させることにあるなら、移行のしやすさは重要である。


Marzipanと呼ばれる秘密のベールに包まれたプロジェクトについて、Appleのファンボーイ的に予想を行なった。実際にどのようなものが出てくるかは、6月のWWDCまでわからない。しかし新しいUIフレームワークを予想するという行為そのものは、単におもしろいだけでなく、UIフレームワークの理想像について考察を深めるきっかけになることだろう。

ぜひ読者諸氏も、Marzipanを予想してみてほしい。

「iOS 11 Programming」を書きました

iOS 11 Programming

iOS 11 Programming

  • 著者:堤 修一,吉田 悠一,池田 翔,坂田 晃一,加藤 尋樹,川邉 雄介,岸川克己,所 友太,永野 哲久,加藤 寛人,
  • 発行日:2017年11月16日
  • 対応フォーマット:製本版,PDF
  • PEAKSで購入する

今日から普通にお買い求めいただけます。

担当した章について宣伝します

僕は「第6章 Drag and Drop」と「第7章 FilesとDocument Based Application」のところを書きました。ここで担当した章について宣伝します。

iOS 11が一般にリリースされてからもう少し経っていますが、実際にiPadを使っていると、Drag and Dropがとても便利な操作であることがわかります。例えばタスク管理に使っているThingsというアプリでは、メールアプリなどからのドロップを受け付けてくれます。メールがきて後で何か対応しないといけない時に、とりあえずTo-Doを作成するのがとても簡単です。ドラッグやドロップはとても直感的で、効率的な操作ですが、アプリが対応しているかどうか一目で見分けることができません。逆説的に、どのアプリでも必ず対応しているようになることで、その価値が高まると言えます。

Document Based Applicationというのは、KeynoteやPages、Numbersのような、ドキュメントを中心としたアプリです。iOS 11ではこのようなアプリを作るのが本当に簡単になりました。ドキュメントブラウザのコンポーネントが提供されていて、iCloud DriveやDropboxとの連携もほとんど意識する必要がなく、ただドキュメントの表示や編集に注力すればよいようになっています。これまでiOSのはアプリを中心とした世界観を構築していましたが、Document Based Applicationではそれと直交する、ドキュメントを中心としたワークフローを提供できます。対応しているアプリはまだ多くありませんが、そこにニーズが存在していることは間違いないでしょう。

iOS 11 Programmingでは、Drag and Dropへの対応やDocument Based Applicationを作成する際に必要となるであろう知識を、大半のAPIについての解説しつつ、網羅的に詰め込みました。ちょっとググっただけでは得られないような内容になっているはずです。個人的な開発からお仕事まで、ぜひご活用ください。

全体的に宣伝します

「Swift 4の新機能とアップデート」や「Xcode 9 の新機能」のようなDeveloper Toolの章や、僕の担当部分や「レイアウト関連の新機能及び変更点」のようなUIKitに関連した章は、ほとんどのiOSアプリエンジニアにとって必ず役に立つでしょう。少し目を通してから開発を始めることで、より効率的に、特にiPhone Xへの対応作業などでは、大きな効果を得られると思います。

「ARKit」や「Core ML」、そして「Metal」の章は、いまiOS以外の分野でも特に重要な技術領域について、iOSにおける実装を一度に学べるまたとない機会と言えるでしょう。ARはついにひろく実用的な技術になりつつあり、機械学習はもはやなくてはならないものですが、さらに現在ではモバイル端末上での活用が進んでいます。そしてMetalのような高パフォーマンスなGPUAPIは様々なプラットフォームで台頭しています。

「Core NFC」と「PDF Kit」はiOS 11で新たに加わったフレームワークですが、どちらも新たな可能性をもたらします。「SiriKit」や「HomeKit入門とiOS 11のアップデート」、「Audio関連アップデート」は、これまでにもあったフレームワークではありますが、iOS 11で着実に進化しつつあります。特にSiriKitは、来年発売されるというHomePodとの連携が期待されるところです。HomePodはGoogle Assistantなどと違って、クラウド上のアプリではなく、iOS端末上のアプリと連携するのがユニークなところですね。

ということで、全体的にiOSアプリの開発をしていたら買うしかないかな、というような内容になっています。どうぞお買い求めください!

iOS 11 Programming

あなたの知らない UIKit の世界 — UITableView に UITextView を置きたい

f:id:cockscomb:20160906232707p:plain:h540:right

UITableView の見た目で様々な要素を表示しつつ、その一部として UITextView を使いたい、という需要があると思う。最も身近な例は標準のメールアプリである。

これは UIKit の API を駆使することで実現可能である。本記事ではこれを実現する方法を通して、あまり知られていないであろう UIKit の機能について紹介する。

UITextView の intrinsic content size

Auto Layout において、UIView は自身が表示されるためにちょうどよいサイズを知っている。具体的には var intrinsicContentSize: CGSize である(Swift 3.0 では property で、Swift 2.3 まではメソッドである)。このサイズは他の NSLayoutConstraint と関連して、デフォルトではプライオリティ 750 で縮みにくく、プライオリティ 250 で広がりにくい。

例えば UIImageView では画像のサイズがこれに相当するし、UILabel では文字列を描画するのに最低限必要なサイズと等しくなるはずである。

ここで UITextView の intrinsic content size はどうかというと、そのままでは widthheight-1CGSize が返る。UITextViewUIScrollView を継承しているので、表示する内容に関わらず、ちょうどよいサイズというものを持たないのである。しかしここで、UIScrollView 由来の var isScrollEnabled: Bool property に false を設定すると、こんどはちょうどよいサイズが返るようになる。

let textView = UITextView()
textView.text = "Think different"

textView.intrinsicContentSize() // {w -1 h -1}

textView.scrollEnabled = false

textView.intrinsicContentSize() // {w 86 h 30}

UITextView の intrinsic content size (Swift 2.3)

この挙動を利用して UITableViewCell の Auto Layout を設定することができる。

ところで UITextViewUIScrollView を継承しているのは正当で、内容となる文字列が非常に長い場合に、表示されていない部分のレイアウトを遅延させることができる。実際に、長大な内容の UITextViewvar contentSize: CGSize を取得すると、場合によっては本来のサイズが返ってこないはずだ。逆に言えばスクロールを無効にした UITextView では、長大な内容を持つ場合に明確なパフォーマンスの低下がみられることに留意するべきである。具体的な仕組みに関しては NSLayoutManager Class Reference の “Noncontiguous Layout” を参照すること。

UITableView の Auto Layout

UITableView の self-sizing cell について、詳細は “Working with Self-Sizing Table View Cells” を参照。

UITextView を置いた UITableViewCell に適切に NSLayoutConstraint を設定する。このとき最小の高さを設定しておく方が良いかもしれない。あとは UITableViewAutomaticDimension を高さとして返す。

UITableViewCell の高さの更新

一度表示された UITextView の内容が変更される場合、単に Auto Layout を利用していれば func invalidateIntrinsicContentSize() の効果で全体が更新されるはずである。一方で UITableViewCell の場合は、高さの再計算が必要であることを UITableView に伝える必要がある。ただし cell をリロードするといったことは避けたい。

UITableView では、func beginUpdates()func endUpdates() を連続して呼び出すことで、 cell の高さを再計算させることができる。これはドキュメンテーションされた挙動である。UITableView Class Reference の beginUpdates() から、discussions 部分を引用する。(強調は筆者によるもの)

Call this method if you want subsequent insertions, deletion, and selection operations (for example, cellForRowAtIndexPath: and indexPathsForVisibleRows) to be animated simultaneously. You can also use this method followed by the endUpdates method to animate the change in the row heights without reloading the cell. This group of methods must conclude with an invocation of endUpdates. These method pairs can be nested. If you do not make the insertion, deletion, and selection calls inside this block, table attributes such as row count might become invalid. You should not call reloadData within the group; if you call this method within the group, you must perform any animations yourself.

このように cell のリロードなしに高さのアニメーションを行えることが明記されている。

これを利用して、var text: String? property を変更したあとや、あるいは var isEditable: Bool が true であればユーザーの入力に合わせて、beginUpdates()endUpdates() を呼び出すことで、Auto Layout と合わせて適切に cell の高さが変化する。

extension ViewController: UITextViewDelegate {

    func textViewDidChange(textView: UITextView) {
        UIView.performWithoutAnimation {
            self.tableView.beginUpdates()
            self.tableView.endUpdates()
        }
    }

}

ユーザーの入力に合わせて高さを更新する。この場合は煩わしいのでアニメーションを無効にしている。(Swift 2.3)

まとめ

UITextView のスクロールを無効にすることで intrinsic content size が変化し Auto Layout でうまく扱えることを示した。これを UITableViewCell に置いて、UITableView の self-sizing cell として利用できることを明らかにした。そして UITableViewbeginUpdates()endUpdates() のドキュメントされた挙動によって、cell の高さを再計算させられることを確認した。

これらの UIKit の挙動はあまり知られていないように思う。ひとつひとつは些細だが、組み合わせることで強力なものとなる。UIKit についての知見を深めることは、すばらしいアプリを開発するための近道である。


以上の内容は8/31の関西モバイルアプリ研究会で発表したものです。

iPhone OS 2.0 とともに UIKit が公開されて、ついには iOS 10 の正式リリースがすぐそこまで迫っている昨今ですが、長年の経験を持つ私たちでさえまだまだ知らないことが山ほどあるように思います。UIKit は非常に巧妙に設計された GUI フレームワークですから、ユーザー体験を改善するために必要な様々な仕掛けが用意されていたりします。

特に近年では、Swift というリッチな言語や、モダンパラダイムやライブラリ、あるいは種々の開発手法などと、モバイルアプリに関する話題には事欠きません。しかしながら初心に立ち返って、慣れ親しんだはずのフレームワークを子細に観察してみると、意外な発見があるかもしれません。今後もライフワークとして学び続けて、API を通して Apple に近づいていきたいと思います。