20191218のSwiftに関する記事は11件です。

RxSwiftのオペレーターの勉強 その2(結合編)

Atrae Advent Calendar 2019 の18日目を担当するアガツマです。
普段は、ビジネス版マッチングアプリ yenta のiOS版を開発をしています。

前回に続いて、RxSwiftのオペレーターについて書きます。
今回は、オペレーターの中でも複数のobservableを合成(結合)するようなものについてまとめました。
RxSwiftを勉強する際の参考になったら嬉しいです。

※RxSwiftのオペレーターとは、Observableに用意されているイベントを加工する用のメソッドのことを指します

複数のobservableを合成するオペレーター

combineLatest

処理
2つのObsrvableのを監視してどちらか1つのObsrvableが送信される時、それぞれのObservableから送信される最新の値をまとめて送信する。

用途

複数の変数を監視する必要がある場合(複数入力のバリデーションなど)
複数の値を組み合わせる処理

コード

        let i = PublishSubject<Int>()
        let s = PublishSubject<String>()

        _ = Observable.combineLatest(i, s) {
            "\($0) + \($1)"
          }
          .subscribe {
            print("onNext: ", $0)
          }

        i.onNext(1)
        s.onNext("A")
        i.onNext(2)
        s.onNext("B")
        s.onNext("C")
        i.onNext(3)

出力結果

onNext:  next(1 + A)
onNext:  next(2 + A)
onNext:  next(2 + B)
onNext:  next(2 + C)
onNext:  next(3 + C)

withLatestFrom

処理
あるObservableがイベントを送信した際に、もう一方の Observabe の最新のイベントを合成する。

用途
1つのアクションが起きた際に、他の値に対して何かしら処理をする場合。
(ボタンが押された時などに、他の要素がどのような値になっているか知りたいときなど)

コード

        let i = PublishSubject<Int>()
        let s = PublishSubject<String>()

        i.withLatestFrom(s)
            .subscribe({ string in
                print("onNext: ", string)
            })
            .disposed(by: disposeBag)

        i.onNext(1)
        s.onNext("A")
        i.onNext(2)
        s.onNext("B")
        s.onNext("C")
        i.onNext(3)

出力結果

onNext:  next(A)
onNext:  next(C)

zip

処理
複数の Observable のイベントを1つづつ順番に合成する。

用途
並列処理をしたい場合(API通信で複数のレスポンスを待ってから処理を進めたいときなど)

コード

        let i = Observable.of(1,2,3,4,5)
        let s = Observable.of("A","B","C")

        _ = Observable.zip(i, s) {
            "\($0) + \($1)"
          }
          .subscribe {
            print("onNext: ", $0)
        }

出力結果

onNext:  next(1 + A)
onNext:  next(2 + B)
onNext:  next(3 + C)
onNext:  completed

おわりに

今回は、RxSwiftでのObservableの合成についてまとめてみました。
このような、オペレーターを組み合わせることで、様々な処理を実現することができるのだなと思いました。
今後は、RxSwiftを用いたアーキテクチャーなどの理解を深めていきたいと思います!

参考

RxSwift研究読本1 入門編
ReactiveX Document

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AVCaptureMultiCamSessionを使用してCameraOSS作成

こんにちはフリーランスの永田です。年始からSwiftUI案件で稼働します。案件獲得のコツは技術を磨くです。

AVCaptureMultiCamSession

MovieSession

上記の最新APIは両画面同時撮影する機能を持ちます。このAPIを使用して11月から作成して、OSS化しました。

https://github.com/daisukenagata/BothSidesCamera

宜しければスターお願い致します。

機能

  • 前面背面同時撮影
  • インナー画面の位置の可変。画面域の可変
  • 2画面同じ比率での撮影
  • 縦画面、横画面の対応(右側、左側対応)

環境

  • Xcode11.3
  • Swift5.0
  • iOS13~

動作の一例

ポイント

全てのOSS機能を言語化するとかなりの文字量になってしまい、何を見ているのか、プログラムのどこにいるのかなどを保ちながら理解するのが、困難になる場合があるので、ポイントを集約します。
プレビュー画面は自作で合わせる。

BothSidesMixer.swiftBothSidesMixer.metal でプレビュー画面を設定しています。

BothSidesMixer.swift のこの部分です。
プログラム内のコメント、Fixed with memory measuresの部分を同じメソッドでやると高速ループの中に入り、高メモリーがさらに高くなるので、
フラグが変更する度に呼び出すようにして、メモリーが上昇するのを極力制御しています。

getMtlSize メソッド

CVPixelBufferを新たに作成する機能です。

mix メソッド

CVPixelBufferPoolCreatePixelBufferCVPixelBufferを作成しています。
fullScreenTextureは全画面
pipTextureはインナー画面
outputTextureMTLTexturefullScreenTexturepipTextureを表示するTextureです。
BothSidesMixer.metal で書き込み処理をしています。

inputImageは2画面同時比率を実現しています。
座標を合わせる技はこちら
CGAffineTransformの合わせ技です。

let inputImage = CIImage(cvImageBuffer: fullScreenPixelBuffer, options: nil).transformed(by: CGAffineTransform(scaleX: 0.5, y: 0.5).translatedBy(x: CGFloat(fullScreenTexture.width/2), y: 0))

fullScreenTextureMTLTextureを作成しています。
makeTextureFromCVPixelBufferMTLTextureを作成するロジックを用意しています。

guard let newfullScreenTexture = makeTextureFromCVPixelBuffer(pixelBuffer: pixelBuffer) else {
     print("AVCaptureMultiCamViewModel_mix")
     return nil
}

var parameters = MixerParameters(pipPosition: pipPosition, pipSize: pipSize)
BothSidesMixer.metalに渡すパラメータを用意します。

let pipPosition = SIMD2(Float(pipFrame.origin.x) * Float(fullScreenTexture.width),Float(pipFrame.origin.y) * Float(fullScreenTexture.height))

let pipSize = SIMD2(Float(pipFrame.size.width) * Float(pipTexture.width),Float(pipFrame.size.height) * Float(pipTexture.height))

var parameters = MixerParameters(pipPosition: pipPosition, pipSize: pipSize)

commandQueueはMetalを使用して、GPUに書き込むプログラムになっています。
commandEncoder.setTextureとMetalFileのreporterMixerメソッドを見ると理解しやすいと思います。
引数が連動しています。

画面を作成しているメソッドの全体像です。

    // Fixed with memory measures
    func getMtlSize(mtl: MTLTexture, sameRatio: Bool) {
        if sameRatio == true && pixelBuffer == nil {
            let options = [
                kCVPixelBufferCGImageCompatibilityKey as String: true,
                kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
                kCVPixelBufferIOSurfacePropertiesKey as String: [:]
                ] as [String : Any]

            cvReturn = CVPixelBufferCreate(kCFAllocatorDefault,
                                           Int(mtl.width),
                                           Int(mtl.height),
                                           kCVPixelFormatType_32BGRA,
                                           options as CFDictionary,
                                           &pixelBuffer)
        }
    }

    func mix(fullScreenPixelBuffer: CVPixelBuffer,
             pipPixelBuffer: CVPixelBuffer,
             _ sameRatio: Bool) -> CVPixelBuffer? {

        guard let outputPixelBufferPool = outputPixelBufferPool else { return nil }

        var newPixelBuffer: CVPixelBuffer?
        CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, outputPixelBufferPool, &newPixelBuffer)

        let outputPixelBuffer = newPixelBuffer
        let outputTexture = makeTextureFromCVPixelBuffer(pixelBuffer: outputPixelBuffer)
        guard var fullScreenTexture = makeTextureFromCVPixelBuffer(pixelBuffer: fullScreenPixelBuffer) else { return nil}
        guard let pipTexture = makeTextureFromCVPixelBuffer(pixelBuffer: pipPixelBuffer) else { return nil}

        if sameRatio == true {
            // Fixed with memory measures
            getMtlSize(mtl: fullScreenTexture,sameRatio: sameRatio)

            if cvReturn == kCVReturnSuccess {
                guard let pixelBuffer = pixelBuffer else {
                    print("AVCaptureMultiCamViewModel_mix")
                    return nil
                }
                let ciContext = CIContext()
                let inputImage = CIImage(cvImageBuffer: fullScreenPixelBuffer, options: nil).transformed(by: CGAffineTransform(scaleX: 0.5, y: 0.5).translatedBy(x: CGFloat(fullScreenTexture.width/2), y: 0))
                let colorSpace = CGColorSpaceCreateDeviceRGB()
                ciContext.render(inputImage, to: pixelBuffer, bounds: inputImage.extent, colorSpace: colorSpace)

                guard let newfullScreenTexture = makeTextureFromCVPixelBuffer(pixelBuffer: pixelBuffer) else {
                    print("AVCaptureMultiCamViewModel_mix")
                    return nil
                }
                fullScreenTexture = newfullScreenTexture
            }
        }

        let pipPosition = SIMD2(Float(pipFrame.origin.x) * Float(fullScreenTexture.width), Float(pipFrame.origin.y) * Float(fullScreenTexture.height))
        let pipSize = SIMD2(Float(pipFrame.size.width) * Float(pipTexture.width), Float(pipFrame.size.height) * Float(pipTexture.height))
        var parameters = MixerParameters(pipPosition: pipPosition, pipSize: pipSize)

        guard let commandQueue = commandQueue,
            let commandBuffer = commandQueue.makeCommandBuffer(),
            let commandEncoder = commandBuffer.makeComputeCommandEncoder(),

            let computePipelineState = computePipelineState else {
                print("BothSidesMixer_computePipelineState")

                if let textureCache = textureCache { CVMetalTextureCacheFlush(textureCache, 0) }

                return nil
        }

        commandEncoder.setComputePipelineState(computePipelineState)
        commandEncoder.setTexture(fullScreenTexture, index: 0)
        commandEncoder.setTexture(pipTexture, index: 2)
        commandEncoder.setTexture(outputTexture, index: 3)
        commandEncoder.setBytes(UnsafeMutableRawPointer(&parameters), length: MemoryLayout<MixerParameters>.size, index: 0)

        let width = computePipelineState.threadExecutionWidth
        let height = computePipelineState.maxTotalThreadsPerThreadgroup / width
        let threadsPerThreadgroup = MTLSizeMake(width, height, 1)
        let threadgroupsPerGrid = MTLSize(width: (fullScreenTexture.width + width - 1) / width,
                                          height: (fullScreenTexture.height + height - 1) / height,
                                          depth: 1)
        commandEncoder.dispatchThreadgroups(threadgroupsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)

        commandEncoder.endEncoding()
        commandBuffer.commit()
        commandBuffer.waitUntilCompleted()

        return outputPixelBuffer
    }

肝心のカメラの合成クラス

主な合成メソッドです。このconfigureBackCameraconfigureFrontCamera の2つのメソッドは録画をする上で、必須でどうしてもメモリが高メモリになってしまいます。iPhonePro11で260MB前後です。

背面カメラの設定configureBackCameraメソッド
前面カメラの設定configureFrontCameraメソッド
音の設定configureMicrophoneメソッド

処理の内容はコード見て理解して下さい。それが一番わかりやすいです。

final class BothSidesMultiCamViewModel: NSObject {

    var session                   = AVCaptureMultiCamSession()
    var aModel                    : BothSidesMultiCamSessionModel?

    var backCamera                             : AVCaptureDevice?
    var backDeviceInput                        : AVCaptureDeviceInput?
    var frontDeviceInput                       : AVCaptureDeviceInput?

    private var microphoneDeviceInput          : AVCaptureDeviceInput?

    private let backCameraVideoDataOutput      = AVCaptureVideoDataOutput()
    private let frontCameraVideoDataOutput     = AVCaptureVideoDataOutput()
    private let backMicrophoneAudioDataOutput  = AVCaptureAudioDataOutput()
    private let frontMicrophoneAudioDataOutput = AVCaptureAudioDataOutput()

    private let dataOutputQueue                = DispatchQueue(label: "data output queue")


    override init() {
        aModel = BothSidesMultiCamSessionModel()
        super.init()
        dataSet()
    }

    // Flash
    func pushFlash() {
        do {
            try backCamera?.lockForConfiguration()
            switch backCamera?.torchMode {
            case .off:
                backCamera?.torchMode = AVCaptureDevice.TorchMode.on
            case .on:
                backCamera?.torchMode = AVCaptureDevice.TorchMode.off
            default: break
            }
            backCamera?.unlockForConfiguration()
        } catch {
            print("not be used")
        }
    }

    func dataSet() {
        aModel?.dataOutput(backdataOutput: backCameraVideoDataOutput,
                              frontDataOutput: frontCameraVideoDataOutput,
                              backicrophoneDataOutput: backMicrophoneAudioDataOutput,
                              fronticrophoneDataOutput: frontMicrophoneAudioDataOutput)
    }

    func screenShot(call: @escaping() -> Void, orientation: UIInterfaceOrientation) { aModel?.screenShot(call: call, orientation: orientation) }

    func changeDviceType() {
        guard let backDeviceInput = backDeviceInput else {
            print("AVCaptureMultiCamViewModel_session")
            return
        }
        session.removeInput(backDeviceInput)
        session.removeOutput(backCameraVideoDataOutput)
        backCamera = nil
    }

    func configureBackCamera(_ backCameraVideoPreviewLayer: AVCaptureVideoPreviewLayer?,deviceType :AVCaptureDevice.DeviceType) {
        session.beginConfiguration()
        defer {
            session.commitConfiguration()
        }

        backCamera = AVCaptureDevice.default(deviceType, for: .video, position: .back)

        guard let backCamera = backCamera else {
            print("BothSidesMultiCamViewModel_backCamera")
            return
        }

        // Camera support is limited.
        if deviceType == .builtInWideAngleCamera {
            do {
                try backCamera.lockForConfiguration()
                backCamera.focusMode = .continuousAutoFocus
                backCamera.unlockForConfiguration()
            } catch {
                print("not be used")
            }
        }

        do {
            backDeviceInput = try AVCaptureDeviceInput(device: backCamera)

            guard let backCameraDeviceInput = backDeviceInput,
                session.canAddInput(backCameraDeviceInput) else {
                    print("AVCaptureMultiCamViewModel_backCameraDeviceInput")
                    return
            }

            session.addInputWithNoConnections(backCameraDeviceInput)
        } catch {
            return
        }

        guard let backCameraDeviceInput = backDeviceInput,
            let backCameraVideoPort = backCameraDeviceInput.ports(for: .video,
                                                                  sourceDeviceType: backCamera.deviceType,
                                                                  sourceDevicePosition: backCamera.position).first else {
                                                                    print("AVCaptureMultiCamViewModel_backCameraVideoPort")
                                                                    return
        }

        guard session.canAddOutput(backCameraVideoDataOutput) else {
            print("AVCaptureMultiCamViewModel_session.canAddOutput")
            return
        }

        session.addOutputWithNoConnections(backCameraVideoDataOutput)
        backCameraVideoDataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)]
        backCameraVideoDataOutput.setSampleBufferDelegate(aModel, queue: dataOutputQueue)

        let backCameraVideoDataOutputConnection = AVCaptureConnection(inputPorts: [backCameraVideoPort], output: backCameraVideoDataOutput)
        guard session.canAddConnection(backCameraVideoDataOutputConnection) else {
            print("AVCaptureMultiCamViewModel_session.canAddConnection")
            return
        }

        session.addConnection(backCameraVideoDataOutputConnection)
        backCameraVideoDataOutputConnection.videoOrientation = .portrait

        guard let backCameraVideoPreviewLayer = backCameraVideoPreviewLayer else {
            print("AVCaptureMultiCamViewModel_backCameraVideoPreviewLayer")
            return
        }

        let backCameraVideoPreviewLayerConnection = AVCaptureConnection(inputPort: backCameraVideoPort, videoPreviewLayer: backCameraVideoPreviewLayer)
        guard session.canAddConnection(backCameraVideoPreviewLayerConnection) else {
            print("AVCaptureMultiCamViewModel_session.canAddConnection")
            return
        }
        session.addConnection(backCameraVideoPreviewLayerConnection)
    }

    func configureFrontCamera(_ frontCameraVideoPreviewLayer: AVCaptureVideoPreviewLayer?, deviceType :AVCaptureDevice.DeviceType) {
        session.beginConfiguration()
        defer {
            session.commitConfiguration()
        }

        guard let frontCamera = AVCaptureDevice.default(deviceType, for: .video, position: .front) else {
            print("AVCaptureMultiCamViewModel_frontCamera")
            return
        }

        do {
            frontDeviceInput = try AVCaptureDeviceInput(device: frontCamera)
            guard let frontCameraDeviceInput = frontDeviceInput,
                session.canAddInput(frontCameraDeviceInput) else {
                    print("AVCaptureMultiCamViewModel_frontCameraDeviceInput")
                    return
            }
            session.addInputWithNoConnections(frontCameraDeviceInput)
        } catch {
            return
        }

        guard let frontCameraDeviceInput = frontDeviceInput,
            let frontCameraVideoPort = frontCameraDeviceInput.ports(for: .video,
                                                                    sourceDeviceType: frontCamera.deviceType,
                                                                    sourceDevicePosition: frontCamera.position).first else {
                                                                        print("AVCaptureMultiCamViewModel_frontCameraVideoPort")
                                                                        return
        }
        guard session.canAddOutput(frontCameraVideoDataOutput) else {
            print("AVCaptureMultiCamViewModel_session.canAddOutput")
            return
        }

        session.addOutputWithNoConnections(frontCameraVideoDataOutput)
        frontCameraVideoDataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)]
        frontCameraVideoDataOutput.setSampleBufferDelegate(aModel, queue: dataOutputQueue)

        let frontCameraVideoDataOutputConnection = AVCaptureConnection(inputPorts: [frontCameraVideoPort], output: frontCameraVideoDataOutput)
        guard session.canAddConnection(frontCameraVideoDataOutputConnection) else {
            print("AVCaptureMultiCamViewModel_session.canAddConnection")
            return
        }

        session.addConnection(frontCameraVideoDataOutputConnection)
        frontCameraVideoDataOutputConnection.videoOrientation = .portrait
        frontCameraVideoDataOutputConnection.automaticallyAdjustsVideoMirroring = false
        frontCameraVideoDataOutputConnection.isVideoMirrored = true

        guard let frontCameraVideoPreviewLayer = frontCameraVideoPreviewLayer else {
            print("AVCaptureMultiCamViewModel_frontCameraVideoPreviewLayer")
            return
        }

        let frontCameraVideoPreviewLayerConnection = AVCaptureConnection(inputPort: frontCameraVideoPort, videoPreviewLayer: frontCameraVideoPreviewLayer)
        guard session.canAddConnection(frontCameraVideoPreviewLayerConnection) else {
            print("AVCaptureMultiCamViewModel_session.canAddConnection")
            return
        }

        session.addConnection(frontCameraVideoPreviewLayerConnection)
        frontCameraVideoPreviewLayerConnection.automaticallyAdjustsVideoMirroring = false
        frontCameraVideoPreviewLayerConnection.isVideoMirrored = true
    }

    func configureMicrophone() {

        session.beginConfiguration()
        defer {
            session.commitConfiguration()
        }

        guard let microphone = AVCaptureDevice.default(for: .audio) else {
            print("AVCaptureMultiCamViewModel_microphone")
            return
        }

        do {
            self.microphoneDeviceInput = try AVCaptureDeviceInput(device: microphone)

            guard let microphoneDeviceInput = microphoneDeviceInput,
                session.canAddInput(microphoneDeviceInput) else {
                    print("AVCaptureMultiCamViewModel_microphoneDeviceInput")
                    return
            }
            session.addInputWithNoConnections(microphoneDeviceInput)
        } catch {
            return
        }
        guard let microphoneDeviceInput = microphoneDeviceInput,
            let backMicrophonePort = microphoneDeviceInput.ports(for: .audio,
                                                                 sourceDeviceType: microphone.deviceType,
                                                                 sourceDevicePosition: .back).first else {
                                                                    print("AVCaptureMultiCamViewModel_microphoneDeviceInput")
                                                                    return
        }

        guard let frontMicrophonePort = microphoneDeviceInput.ports(for: .audio,
                                                                    sourceDeviceType: microphone.deviceType,
                                                                    sourceDevicePosition: .front).first else {
                                                                    print("AVCaptureMultiCamViewModel_frontMicrophonePort")
                                                                    return

        }

        guard session.canAddOutput(backMicrophoneAudioDataOutput) else {
            print("AVCaptureMultiCamViewModel_session.canAddOutput")
            return
        }

        session.addOutputWithNoConnections(backMicrophoneAudioDataOutput)
        backMicrophoneAudioDataOutput.setSampleBufferDelegate(aModel, queue: dataOutputQueue)

        guard session.canAddOutput(frontMicrophoneAudioDataOutput) else {
            print("AVCaptureMultiCamViewModel_session.canAddOutput")
            return
        }

        session.addOutputWithNoConnections(frontMicrophoneAudioDataOutput)
        frontMicrophoneAudioDataOutput.setSampleBufferDelegate(aModel, queue: dataOutputQueue)

        let backMicrophoneAudioDataOutputConnection = AVCaptureConnection(inputPorts: [backMicrophonePort], output: backMicrophoneAudioDataOutput)
        guard session.canAddConnection(backMicrophoneAudioDataOutputConnection) else {
            print("AVCaptureMultiCamViewModel_session.canAddConnection")
            return
        }

        session.addConnection(backMicrophoneAudioDataOutputConnection)

        let frontMicrophoneAudioDataOutputConnection = AVCaptureConnection(inputPorts: [frontMicrophonePort], output: frontMicrophoneAudioDataOutput)
        guard session.canAddConnection(frontMicrophoneAudioDataOutputConnection) else {
            print("AVCaptureMultiCamViewModel_session.canAddConnection")
            return
        }

        session.addConnection(frontMicrophoneAudioDataOutputConnection)
    }
}

以上、AVCaptureMultiCamSessionを使用して作成したOSSの紹介をさせていただきました。

来年も皆様、良い一年になりますように。

貴重なお時間、お読み下さいまして、誠にありがとうございます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【iOS】サポートバージョンを上げた時にやったこと

概要

アプリを長い間開発していると、必ず生じるサポートバージョン問題。
そんなサポートバージョンを上げた時にどこを変更するのか忘れそうなので
備忘録的な意味合いも兼ねて更新したいと思います。

iOS Deployment Target

まぁ、これは言わずもがなですね。
ここで内部的には判定されているものと思われます。

@available属性

旧バージョンに対応していた場合、新しいOSでしか対応していないAPIを使う際には
@available属性などで条件分けして実装していたと思います。
これが必要なくなります。

@available(iOS, [version], *)

[version]の値が今回のサポートバージョンに含まれている場合は
消してしまって問題ありませんので消してしまいましょう!

また、この表記がある場合、この[version]に満たないバージョンで
代わりに呼ばれるメソッドがあるはずなので、そちらも消してしまいましょう!

Podfile

忘れがちなのが、これではないでしょうか。
(今回この記事を書いたのは、これを忘れてたからです。)

platform :ios, "9.0"

みたいな表記があると思うので、これを今回サポートするバージョンへと変更して
pod install を叩きましょう。
多くの場合は、ほぼほぼ変更点はない(checksumのみ)と思います。

他に皆様でやったことがあれば教えていただけると嬉しいです!

誰かのお役に立てば。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

プロパティをクロージャで初期化する(Initialization Closure)

前回の投稿から長い時を経て、就職してiOSエンジニアとして日々勉強をしています。よろしくお願いします。
今回はタイトルの通り、Initialization Closureについてまとめようと思います。

(この記事は就職先の技術ブログの内容を転機したものです)

TL; DR

  • Initialization ClosureはStored Propertyの初期化に使われる書き方。
  • Computed Propertyではないので、{}の中は1度しか呼ばれない

未知との遭遇

現在開発中のアプリでUIViewControllerのライフサイクルとAuto Layoutの反映タイミングに悩まされ、viewDidLayoutSubview()で最初の一回だけ処理をしたくなり、この記事にたどり着きました。

実際に以下のようなコードを書けば問題は解決したのですが、何がどうなっているのか・なぜ上手くいってるのかがわかりませんでした。

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    _ = initCollectionViewFlowLayout
}

private lazy var initCollectionViewFlowLayout : Void = {
    // ここで色々設定する
    // frame、boundsとか使う
}() 

何がわからんのか

当時わからなかったことをまとめるとこんな感じだったと思います。

  • 見た目Computed Propertyと似てるけど何が違うのか
  • 何故これでinitCollectionViewFlowLayoutの中身が一度しか呼ばれないのか
  • {}の後の()は何者なのか

最近ようやくこの謎が解けた(っぽい)ので、これらの点について話をします。

Computed Propertyとの違い

変数名の後に{ return }みたいなのがあるのでComputed Propertyと混同してしまったのですが、このinitCollectionViewFlowLayoutはInitialization Closureという書き方でStored Propertyとして定義されているみたいです(=があるのでそれもそうか...という感じ)。

Initialization Closureとは

Stored Propertyの初期化の際に、初期値としてクロージャの実行結果を渡す書き方のことです。

例1 ) 要素数50のフィボナッチ数列 (Stored Property)

let fibonacci: [Int] = {
    var temporaryArray = [1, 1]
    let numberOfElement = 50

    for _ in 0..<numberOfElement - temporaryArray.count {
        let nextElement = temporaryArray[temporaryArray.count - 2] + temporaryArray[temporaryArray.count - 1]
        temporaryArray.append(nextElement)
    }

    return temporaryArray
}()

上の例では、フィボナッチ数列の初期化を行うクロージャの返り値を、定数fibonacciに渡しています(return temporaryArray)。
{}の中で初期化の処理をクロージャとして定義して、最後に()をつけることでそのクロージャを即実行して[Int]の値を取得しているという感じです。
最後の()を付けないと、fibonacciにクロージャ(この場合() -> [Int]のクロージャ)を代入する形になってしまい、コンパイルエラーが起きます。

何故処理が一度しか呼ばれないのか

答えはシンプルで、initCollectionViewFlowLayoutStored Propertyだからです。
Computed Propertyは値を保持しないので参照するたびに{}の中の計算が走ります(例2)が、
Stored Propertyの場合は値を保持するので、参照しても{}内の計算は行われず、初期化の時に{}内でreturnされた値を使うだけになります(例3)。

例2 ) 要素数50のフィボナッチ数列 (Computed Property)

var fibonacci: [Int] {
    var temporaryArray = [1, 1]
    let numberOfElement = 50

    for _ in 0..<numberOfElement - temporaryArray.count {
        let nextElement = temporaryArray[temporaryArray.count - 2] + temporaryArray[temporaryArray.count - 1]
        temporaryArray.append(nextElement)
    }

    print("おるで")

    return temporaryArray
}

for i in 0..<fibonacci.count {
    print(fibonacci[i])
}

おるで // fibonacci.countでの参照で呼ばれている
おるで // 以降、print(fibonacci[i])での参照で呼ばれている
1
おるで
1
おるで
2
(中略)
おるで
4807526976
おるで
7778742049
おるで
12586269025

例3 ) Initialization Closureが1度しか呼ばれていないことの確認

let fibonacci: [Int] = {
    var temporaryArray = [1, 1]
    let numberOfElement = 50

    for _ in 0..<numberOfElement - temporaryArray.count {
        let nextElement = temporaryArray[temporaryArray.count - 2] + temporaryArray[temporaryArray.count - 1]
        temporaryArray.append(nextElement)
    }

    print("おるで")

    return temporaryArray
}()

for i in 0..<fibonacci.count {
    print(fibonacci[i])
}
おるで // 初期化のタイミングで呼ばれている
1
1
2
(中略)
4807526976
7778742049
12586269025

まとめ

ここまでの内容をまとめると、今回のinitCollectionViewFlowLayoutは、Initialization Closureの正規の使い方というよりかは、その仕組みを応用した手法なのかな?と感じました。これ思いついた人賢いな〜〜と感心しました。

ここまで理解するのにかなり時間がかかってしまいましたが、また便利そうな手法を1つ知ることができたのでこれから上手く使っていこうと思います。何か間違い等あればご指摘お願いします。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】Partial application of 'mutating' method is not allowedってなんだ

structを使っていたら、

Partial application of 'mutating' method is not allowed

というエラーに遭遇しました。
僕の場合、原因は別件だったんですが、日本語情報があまりないエラーメッセージだったので、記事にしてみます。

僕のエラーの原因

僕の場合、

protocol aDelegate {
    var aView: ViewClass
}

struct AIRadarMapBorderRenderer {

    private weak var delegate: aDelegate?

    private mutating func aFuncation() {
        switch aView.aVariable { //この行で"Partial application of 'mutating' method is not allowed"
        case .any
            //any
        default:
            //any
        }
    }
}

という書き方をしたらこのエラーが出ました。

switch aView.aVariable

ここを

switch delegate?.aView.aVariable

とすればOKでした。

それで終わりなんですが、ついでなんでエラーメッセージの意味も調べてみました。

Partial application of 'mutating' method is not allowedってなんだ

Partial application

Partial applicationは、日本語にすると部分適用と訳される技術用語です。
関数型プログラミングっぽい用語で、日本語記事見ると、だいたいカリー化と一緒に紹介されている用語です。
複数の引数を持った関数に対して、普通は全変数に値を渡して、戻り値を得ます。
たとえば、f(a,b,c) = a + b + c という関数があったら、f(1,1,1) = 1 + 1 + 1 = 3 ですね?
これを部分適用だと、

f(a,b,c) = f(a, 1, 1) = f(a) + 2 = f(1) + 2 = 3
ただしf(a) = a, f(b, c) = b + c

みたいな計算ができます。
命令形言語使ってると、「何が嬉しいんだ……?」という気になりますが、
関数型言語だと関数が汎用的なので、利便性のために汎用性を減らしたいときがあるみたいです。

Partial application of 'mutating' method is not allowedの例

Partial application of 'mutating' method is not allowed

エラーメッセージでググったら一番上に出てくるのがこちらの記事。

struct MyStruct {
    var count = 0

    mutating func add(amount: Int) {
        count += amount
    }
}

var myStruct = MyStruct()

[1, 2, 3, 4].forEach(myStruct.add)
// Partial application of 'mutating' method is not allowed

想定している結果としては、1+2+3+4でcount=10となるのを想定していると思われます。
そもそもforEachの中にカッコ省略してメソッド書いて動くのか……?とかはあるんですが、とりあえず"Partial application of 'mutating' method is not allowed"でエラー。
記事の中でも動くコードの例は出てますが、下記でも動きます。

動く例
var count = 0
[1, 2, 3, 4].forEach({ amount in count += amount })
print(count) //10

なぜこれがエラーになるかというと、内部的には

myStruct.add(1)

myStruct.add(2)

myStruct.add(3)

myStruct.add(4)

myStruct.add(1,2,3,4) ※この関数の場合ここでは何もせず

という順番で処理する必要があり、これが部分適用になる訳ですね。

Swiftで部分適用?

Swiftで部分適用(カリー化)にある、

func addTwoNumbers(a: Int)(b: Int) -> Int {
  return a + b
}

let add1 = addTwoNumbers(1)
add1(b: 2) //< 3

という書き方がその昔Swiftオフィシャルでできたらしいのですが、Swiftいくつからなのかは不明ですが、廃止されました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Sketchと1対1を目指すAtomic designなSwiftUIの作り方

はじめに

VALU Advent Calendar 2019 11日目 (!) の記事です!

VALU ではデザインに Atomic design (アトミックデザイン) を採用しています。
本記事では,VALU のデザイナーが作成した素敵な Sketch シンボルを差分なく反映し,かつ変更に耐えうる SwiftUI を運用するための方法をご紹介しようと思います。

昨年の VALU Advent Calendar にて,Sketchと1対1を目指すAtomic designなStoryboardの作り方 を投稿しました。本記事はそれを SwiftUI で行ったものです。まだご覧になっていない方は先にご覧ください。

Highlights

  • SwiftUI を利用することで,「ファイル数増加」「子View更新時の伝播」「親ViewのConstraints破棄」など,UIKit (昨年の実装) での懸念点が全て解決された
  • Atomic design x SwiftUI の親和性はとても高く,ほとんど1対1で再現することができた
  • SwiftUI が迫る今,我々 iOS エンジニアはどう闘っていくのだろうか

昨年の実装方法を振り返る

昨年作成した Storyboard および Xib を以下に改めて掲載します。
Sketch の Level と同様に,細かい単位で Xib ファイルを生成した後,StackView を利用することにより,増減に強い UI の作成が可能となりました。
一見完全に再現できているように見える一方で,View の変更が追従しない問題 など,開発する上での限界も存在していました。
これらの問題は,SwiftUI によって解決されたのでしょうか?

ss 2018-12-11 at 0.39.31.png
ss 2018-12-11 at 2.16.44.png

Atomic にファイルを分割することによって発生した問題

Atom レベルから Xib と,それに対応する Swift ファイルを生成したことにより,ファイル数が多くなる問題 が生じます。
この問題は,最終的にコンパイルさえ通らなくなるという予期せぬ事態を生む結果となりました。

本件につきましては,別の記事で詳細とその解決策を記しています。Atomic design 自体が悪かった,ということではないエラーでしたが,情報共有のため以下に掲載しておきます。

【Xcode】細分化する iOS Architecture に向き合う上で気をつけなければらないただひとつのエラーについて

Atomic design (アトミックデザイン) とは

atomic-design.png

Atomic Design で有名なこのイラスト。上のイラストはそれを端的に説明しています。

Atomic Design とは,ひとつのページを個々の細かなコンポーネントを組み合わせて作成するデザインの方針を指します。

Atom (原子),Molecules (分子),Organisms (生体),Templates,Pages

その Atomic (原子-の) の名と矢印の方向が指す通り,個々の小さな汎用部品を元に,より大きな View が構成されています。

Sketch に合わせた View ファイルの作成

Sketch では,Atom,Molecules,といった表現が「Lv」によって示されています。
低レベルのシンボルを元に,より大きなシンボルを作成していく形となっています。

以下は,弊社デザイナーさんから受け取った,素敵な Sketch ファイルです。

ss 2018-12-11 at 0.38.55.png
ss 2018-12-11 at 0.38.44.png
ss 2018-12-11 at 0.33.26.png

これを,シンボル毎に SwiftUI ファイルにしていきます。

今回は前回同様,複数の View から構成され,いいね等のアクションを行なう画面 (Lv2/Post/Action/Icon Button) に焦点を当てていきます。

Atom の作成

まずは Atom の作成です。ActionCountableView という View を作成します。

ss 2018-12-11 at 11.03.42.png

最初だけ振り返りつつ進みます。昨年の実装は上の通り,Xib ファイルと Swift ファイルを用意し,IB 上またはコード上で値を指定していました。

以下が SwiftUI にて作成した内容です。SwiftUI は,左側のコードと右側のプレビューが対応しており,プレビュー画面での変更が即時にコードとして反映されます。ですので,編集するファイルは .swift ファイルのみとなります。

screenshot 483.png

#if DEBUG で囲まれた部分はデバッグ用のプレビューに当たる部分であり,View の構成に必要な struct 部分はたった 20 行で表現できます (Xib を使った昨年の実装は Xwift ファイル 12 行 + Xib ファイル 50 行 → 2 ファイル 62 行)。

驚くほど簡単に View を生成することができたのが分かるでしょう。さらに,プレビュー用の記述を追加することで,画像の差し替えや値のランダム化,ダークモード時の様子まで確認することができるようになっています。

これだけでもいくつか SwiftUI としてのテクニックがあります。

1 . Button のハイライト領域

SwiftUI では,ボタンは Button 構造体を利用しています。いくつか初期化方法がありますが,ボタンとして認識させ,かつタップ領域としてハイライトさせるためには,Button<Label> : View where Label : ViewLabel 部分がボタン UI として認識されます。Label は protocol View に適合した Generics となっています (SwiftUI では UIKit で言う UILabelText に相当します)。
init(action: @escaping () -> Void, @ViewBuilder label: () -> Label) を利用して ボタン内部に View を描く ことによって,ハイライト領域を指定しています。

2 . Image の色変更およびサイズ変更

.renderingMode(_:).template 指定することにより,色を変更することができます。
また,.frame(width:height:) でサイズを変更する前には,.resizable() を入れサイズが可変であることを明示的に指定しなければなりません。

3 . static func によるプレビュー内再利用

ダークモード対応など,同じ Atom の内容を複数の環境で一覧したい場合があるかと思います。プレビューに用いられる static var previews: some View は static ですので,再利用したい View を別の static な変数または関数に切り出しておくと,スタブを注入する手間も省けるためお得です。

Molecule の作成

次は,先ほど作成した Atom を複数組み合わせた Molecule に当たる View を作成していきます。
Sketch では,この View に対して Override を活用しつつそれぞれの画像を挿入していました。

Atomic Design に従った場合,こちらも同様に変更することが可能です。

昨年と異なる点は,値を保ちつつ複数の画面にコードを共有させることができる※ 点です。
Xib のコピペから開放されることで,子 View での変更が 親 View への変更に常に追従できるようになります。これは UI がコードのみから生成できるようになった利点でもあります。

UIKit の StackView のように HStack に積んでいるだけですが,Spacer() を利用することで,長さ指定のない UIView 同様伸縮するようになります。

screenshot 484.png

@IBDesignable を利用した生成方法等によってこれまでも Atoms または Molecules レベルのソースコードを共有することができました。一方でその方法は,Xcode のコンパイル時間が長くなってしまったり,ソースコードの量によって最終的には IB 上で描画が実行されなくなってしまうなどの問題があり,昨年度は採用しなかった経緯があります。

Organism の作成

Organisms に入ります。ここで一旦内容を以下のような struct にまとめておきます。後にデータをリスト状に表示させるため,Hashable にも適合させることが必要です (ここでは処理を簡略にするためImage を直接プロパティとして持たせていますが,Domain 層にある API 通信の結果だと想像してください)。

screenshot 494.png

投稿内容を反映する PostView を作成します。個々の View は Molecules において作成済みですので,VStack を利用して詰み上げるだけで完成します。

screenshot 496.png

投稿画像など,存在しない可能性がある場合は AnyViewEmptyView の組み合わせによって動的に実現が可能です。三項演算子を利用していますが,どちらの View も AnyView でラップしてあげることで結果を返し,異なる View を切り替えることができます。

Page の作成

最後に,Page の作成です。
ScrollView 内部に ForEach を作成し,Hashable に適合した Post 構造体をもとに投稿内容を反映しています。ダークモードにも対応すると,プレビューも映えますね。

画像を見れば分かる通り,高さは内容によって自動で調整が入るようになっています。ここまで来れば,もう描画についての責任は Pages から離れることとなり,スクロール処理や遷移等の動作に関わる状態に集中できるようになるでしょう。

実際の開発では,ここから @State として記述している部分を,@ObservedObject var viewModel に置き替え,通信部分や画面遷移の処理を書いていくことになります。

screenshot 495.png

まとめ

今回は,過去に使ったものと同じデザインを利用し,SwiftUI を用いて改めて Atomic design に向き合いました。
その結果,SwiftUI を利用することで,「ファイル数増加」「子 View 更新時の伝播」「親 View の Constraints 破棄」など,昨年の UIKit での懸念点が全て解決され,View をデザイン通りに記述することができるようになりました。

さらに,UIKit を用いるよりも格段に Sketch に近づいた印象です。これがコードのみで表現されるのは当時は想像もつかなかったのではないでしょうか。これ,HTML で言う CSS を使ってないんですよ!?
Atomic design x SwiftUI の親和性はとても高く,React の知見と上手く融合していくのだと感じました。
これからコード生成プラグインが React のように SwiftUI にも対応するような流れになれば,UI 部分はもっと進化していくのではないでしょうか。

一方で,危惧されることが 1 点あります。
(SwiftUI で 1 アプリを作った程度の人間でも)数時間でデザイナーの意図する画面を 1 対 1 として容易に実現可能な点です。これはひとつに,デザイナーが UI 層に進出することを容易に想像できる1つの証拠です。

現在の SwiftUI では,スクロール時のページング処理など,ちゃんとした機能を利用しようとすると View が大変複雑になります。Atomic design との親和性は高まったものの,どこまで似せるのか,SwiftUI の得意不得意にどう対応していくのか。

簡単な UI であれば誰でも作れるようになった世界において,領域を奪われた iOS エンジニアの勝負どころはどこになるでしょうか。証明書周りやリジェクト対応と答えた人はセンスがあると思います。実際には ViewModel 以後ですよね。そうなった際にエンジニアとしての価値が再度問われるわけです。

あなたはどうですか?何ができますか?どこまでできますか? と。

References

Sketchと1対1を目指すAtomic designなStoryboardの作り方
Atomic design
Apple Design Resources
Human Interface Guidelines
センスのある某での呟き

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Property Wrapper 入門

Rx を使ってプロパティの変更通知をしたい、だけどクラス外からは値の変更を許したくないというユースケースがよくあると思います。

そうしたとき、以前までは以下のように書いていました。

struct Person {
  private let _age: BehaviorSubject<Int>
  var age: Observable<Int> { _age.asObservable() } 

  init(initialAge: Int) {
    _age = BehaviorSubject(initialAge)
  }
}

しかし、変更通知をしたいだけなのにわざわざこんなに書くのは冗長極まりないです(個人の感想です)。

そこで、 Swift 5.1 で追加された新たな機能、 Property Wrappers の出番です。

最初に以下のようなオブジェクトを用意します。

@propertyWrapper
struct PropertyPublished<Value> {
  private(set) var wrappedValue: Value {
    didSet {
      subject.onNext(wrappedValue)
    }
  }

  var projectedValue: Observable<Value> {
      subject.asObservable()
  }

  private let subject: BehaviorSubject<Value>

  init(wrappedValue: Value) {
      self.wrappedValue = wrappedValue
      subject = BehaviorSubject(value: wrappedValue)
  }

  mutating func onNext(_ newValue: Value) {
      wrappedValue = newValue
  }
}

あとは変更通知したいプロパティの前に @PropertyPublished をつけるだけです。

struct Person {
  @PropertyPublished var age: Int

  init(initialAge: Int) {
    _age = PropertyPublished(wrappedValue: initialAge)
  }

  mutating func increment() {
    _age.onNext(age + 1)
  }
}

// 使い方
var person = Person(initialAge: 10)
person.$age.subscribe(onNext: { print("Age: \($0)") })
print(person.age)
person.increment()

// Output:
//   Age: 10
//   10
//   Age: 11

ずいぶんすっきりしました。


さて、すごくシンプルに書けたのはいいのですが、上の例で Swift 5.1 未満では謎な文法がありました。

Person 内ででてきた _agePerson では明示的に定義していません。
使うときに出てきた person.$agePerson では明示的に定義してません。
また、PropertyPublished 内で定義した wrappedValueprojectedValue は他の名前では代替できません。

これらは全て Property Wrapper の機能として組み込まれており、とても柔軟で便利な機能を実現することができます。

ここでは Property Wrapper の基本的な動作について、見ていきます。

基本

Property Wrapper の最低限の用件は以下です。

  • Property Wrapper 型にしたい型に @propertyWrapper をつける
  • Property Wrapper 型は wrappedValue というインスタンスプロパティを持つ。

これだけです。

実際に作っていきましょう。
まず Property Wrapper 型にしたい型に @propertyWrapper をつけます。

こうすることでコンパイラーに「この型は Property Wrapper 型です」ということを伝えます。

@propertyWrapper
struct PropertyPublished {
}

この時点でコンパイルすると、

Property wrapper type 'PropertyPublished' does not contain a non-static property named 'wrappedValue'

と怒られます。
Property Wrapper 型は wrappedValue というプロパティを持つことが最低条件でした。
なので wrappedValue を追加します。

@propertyWrapper
struct PropertyPublished<Value> {
  var wrappedValue: Value
}

これで最低条件は満たせました。
実際に使ってみましょう。
使い方も簡単でプロパティの前に @{Property Wrapper 型の名前} をつけるだけです。

struct Person {
  @PropertyPublished(wrappedValue: 10) var age: Int
}

こうすることで上のコードをコンパイラーは以下のように展開します。

struct Person {
  private var _age: PropertyPublished<Int> = PropertyPublished(wrappedValue: 10)
  var age: Int {
    get { _age.wrappedValue }
    set { _age.wrappedValue = newValue }
  }
}

まさに Property を Wrap してますね!

ちなみに @{Property Wrapper 型の名前} の後に () をつけることでその Property Wrapper 型のイニシャライザを呼び出すことができるのですが、 .init(wrappedValue:) の呼び出しだけは糖衣構文として以下が用意されています。

@PropertyPublished var age: Int = 10
// ↑ は ↓ と等価
// @PropertyPublished(wrappedValue: 10) var age: Int

とてもシンプルですね!

ただこのままではプロパティの変更を検知できません。
なので Observable を外に見える形で公開しなければなりません。

ここで Property Wrapper で便利な機能である projectedValue の出番です。

以下のように projectedValue: Observable<Value> なプロパティを追加します。

@propertyWrapper
struct PropertyPublished<Value> {
  var wrappedValue: Value

  var projectedValue: Observable<Value> {
    subject.asObservable()
  }

  private let subject: BehaviorSubject<Value>

  init(wrappedValue: Value) {
    self.wrappedValue = wrappedValue
    self.subject = BehaviorSubject(value: wrappedValue)
  }
}

値の更新を購読可能にするために BehaviorSubject も一緒に追加しました。
そして projectedValue はただ BehaviorSubjectObservable として公開しているだけです。

こうすることにより、

struct Person {
  @PropertyPublished(wrappedValue: 10) var age: Int
}

は以下のように展開されます。

struct Person {
  private var _age: PropertyPublished<Int> = PropertyPublished(wrappedValue: 10)

  var $age: Observable<Value> {
    _age.projectedValue
  }

  var age: Int {
    get { _age.wrappedValue }
    set { _age.wrappedValue = newValue }
  }
}

$age という変数が追加されました。
また、このときのプロパティの可視性は age と同じく internal です。
これにより、もともとラップしたプロパティの型は変えずに新たにプロパティの型を外に公開できます。

また、今回は projectedValue には getter しか定義しませんでした。
当たり前と言えば当たり前ですが、 wrappedValueprojectedValue が get-only で setter がない場合や、 private(set) などで setter が外部に公開されていない場合は同じく展開されたプロパティにも setter は生えません。

上記までの実装で必要なプロパティは揃えることができました。
あとは実際に新しい値がセットされたとき、PropertyPublishedsubject を発火させてあげればいいだけです。

ということで発火させる用のメソッドを PropertyPublished に追加してあげます。

@propertyWrapper
struct PropertyPublished<Value> {
  private(set) var wrappedValue: Value {
    didSet {
      subject.onNext(wrappedValue)
    }
  }

  var projectedValue: Observable<Value> {
    subject.asObservable()
  }

  private let subject: BehaviorSubject<Value>

  init(wrappedValue: Value) {
    self.wrappedValue = wrappedValue
    self.subject = BehaviorSubject(value: wrappedValue)
  }

  func onNext(_ newValue: Value) {
    self.wrappedValue = newValue
  }
}

変更通知を飛ばすのは onNext メソッドを通して欲しいため wrappedValueprivate(set) にしています。

これにより age が get-only になりました。

また、 最初から自動で生成されていた _age は private な変数なのでもちろん Person 内からならアクセスできます。

struct Person {
  @PropertyPublished var age: Int = 10

  mutating func increment() {
    _age.onNext(age + 1)
  }
}

この Person.age を監視する方法は以下です。

let person = Person()
person.$age.subscribe(onNext: { /* 処理 */})

ずいぶん短いコードで

  • 本来見せたいプロパティの値である Value
  • 外部からは見える Observable<Value>
  • 外部からは見えない Subject<Value>

を達成することができました!

Swift 5.0 以前でこれを実装しようとすると以下のように書かなければなりません。

struct Person {
  private let _age: BehaviorSubject<Int>
  // `$` はユーザー定義変数では使えない
  var observableAge: Observable<Int> { _age.asObservable() } 
  var age: Int = 10 {
    didSet {
      _age.onNext(age)
    }
  }

  init() {
   _age = BehaviorSubject(value: age)
  }

  mutating func increment() {
    age = age + 1
  }
}

長いですね。
また、これはプロパティが増えるごとに追加しなければなりません。
Property Wrappers を使えばただ @PropertyPublished を追加していけばいいだけです。

struct Person {
  @PropertyPublished var age: Int
  @PropertyPublished var name: String

  init(age: Int, name: String) {
    self._age = PropertyPublished(wrappedValue: age)
    self._name = PropertyPublished(wrappedValue: name)
  }
}

シンプルでいいですね!

ただ、ちょっと残念なのが、

self.age = age

と書けずに

self._age = PropertyPublished(wrappedValue: age)

になっている点です。

Property Wrapper の Proposal には

@Lazy var x: Int
// ...
x = 17   // okay, treated as _x = .init(wrappedValue: 17)

とあるので、 init 内ならいけないかなーと思ったのですが、

Cannot assign to property: 'age' is a get-only property

でコンパイルエラーになるのでダメみたいですね。。。
なので大人しく直接 _age を初期化しています。

ただ、前までのように大量にプロパティを追加していく必要もなく、同じような処理を何度も書かなくていいので非常に便利です。

制限

Property Wrapper はとても便利なのですが、便利が故にいろいろ制限があります。

  • ラップしたプロパティは protocol で使用できません
  • ラップしたインスタンスプロパティを extension では宣言できません
  • ラップしたインスタンスプロパティを Enum では宣言できません
  • class 内で宣言されたラップしたプロパティは override できません
  • ラップしたプロパティには lazy@NSCopying@NSManagedweakunowned をつけられません
  • ラップしたプロパティはそれを囲む宣言内で唯一のプロパティでなければなりません
    • @PropertyPublished var (x, y) = ... は不正
  • ラップしたプロパティは getter と setter を持てません
  • Property Wrapper 型と wrappedValue は、同じアクセス権を持ちます
    • PropertyPublished を public にしたなら wrappedValue も public にしないとダメ
  • init(wrappedValue:) を宣言した場合、Property Wrapper 型と init(wrappedValue:) は、同じアクセス権を持ちます
  • projectedValue を宣言した場合、Property Wrapper 型と projectedValue は、同じアクセス権を持ちます
  • init() を宣言した場合、Property Wrapper 型と init() は、同じアクセス権を持ちます

将来追加される(と思われる)機能

Property Wrappers は今でも結構便利ですが、まだまだフル機能ではありません。
Proposal に将来実装される(予定)機能があるので紹介します。

より細かいアクセス制御

現状、_age$age のアクセス権は固定になっており、制御できません(_age は private、 $age はラップしたプロパティと同じ)。

なのでこれらも使う側で制御できるようにしようという提案があります。

現状考えられているのは以下のような構文。

@PropertyPublished
public internal(storage) private(projection) var foo: Int = 0

上のように書くと、以下のように展開される予定です。

internal var _foo: PropertyPublished<Int> = PropertyPublished(wrappedValue: 0)
private var $foo: PropertyPublished<Int> { _foo.projectedValue }
public var foo: Int { _foo.wrappedValue }

便利なのですが、初見では困惑しそうですね。

ラップされたプロパティを持つインスタンスを参照する

現状の Property Wrapper 型はラップされたプロパティを所持しているインスタンスにアクセスできません。

なので現状手動で PropertyWrapper 型へ伝える必要があります。

protocol Observed {
  func broadcastValueWillChange<Value>(newValue: Value)
}

@propertyWrapper
struct BroadcastObservable<Value: Equatable> {
  private var stored: Value

  var wrappedValue: Value {
    get { stored }
    set {
      if newValue != stored {
        observed?.broadcastValueWillChange(newValue: newValue)
      }

      stored = newValue
    }
  }

  private var observed: Observed? = nil

  init(wrappedValue: Value) {
    self.stored = wrappedValue
  }

  mutating func register(_ observed: Observed) {
    self.observed = observed
  }
}

// 今回は話の都合上 class になります
class Person: Observed {
  @BroadcastObservable var age: Int = 0

  init() {
    _age.register(self)
  }

  func broadcastValueWillChange<Value>(newValue: Int) {
    // action
  }
}

ただこのコードには様々な問題があります。
まず _age.register(self) を手動で呼び出す必要があるため、忘れる可能性があります。

また、broadcastValueWillChange<Value>(newValue:) 内で age プロパティを読むとメモリの排他アクセス違反が起きる可能性があるため、アクセスしてはいけません。

試しに以下のようなコードを実行してみます。

class Person: Observed {
  @BroadcastObservable var age: Int = 0

  init() {
    _age.register(self)
  }

  func broadcastValueWillChange<Value>(newValue: Int) {
    print(age)
  }
}

var person = Person()

for i in Array(1...5) {
  person.age = i
}

person.age が変更されるたびに print するようにしています。

これを実行すると以下のようなエラーを吐いてクラッシュしてしまいます。

Simultaneous accesses to 0x100cba3a0, but modification requires exclusive access.
Previous access (a modification) started at `Person.age.setter + 85 (0x100002db5).
Current access (a read) started at:
...

Person.age の setter 内で Person.age を読むのはダメですよと言われていますね。

これらの問題を解決するために、以下のような構文が提案されています。

@propertyWrapper
struct BroadcastObservable<Value> {
  private var stored: Value

  init(wrappedValue: Value) {
    self.stored = wrappedValue
  }

  static subscript<OuterSelf: Observed>(
      instanceSelf observed: OuterSelf,
      wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
      storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
    ) -> Value {
    get {
      observed[keyPath: storageKeyPath].stored
    }
    set {
      let oldValue = observed[keyPath: storageKeyPath].stored
      if newValue != oldValue {
        observed.broadcastValueWillChange(newValue: newValue)
      }
      observed[keyPath: storageKeyPath].stored = newValue
    }
  }
}

var wrappedValue: Value が消えて代わりに static subscript<OuterSelf: Observed>(instanceSelf:wrapped:storage:) が生えました。

そして BroadcastObservable へのアクセスは observed (ラップしたプロパティを持っているインスタンス) と storageKeyPath (e.g. _age プロパティの KeyPath) を使ってアクセスします。

これで少し複雑になりましたが、wrappedValue の setter アクセス時に getter が呼ばれることがなくなったため、メモリの排他アクセス違反が起きなくなりました。

上のような構文を書くと使う側では以下のように展開されます。

class Person: Observed {
  @BroadcastObservable var age: Int = 0

  // ↑ は ↓ に展開される
  private var _age: BroadcastObservable<Int> = BroadcastObservable(wrappedValue: 0)
  public var age: Int {
    get { BroadcastObservable<Int>[instanceSelf: self, wrapped: \Person.age, storage: \Person._age] }
    set { BroadcastObservable<Int>[instanceSelf: self, wrapped: \Person.age, storage: \Person._age] = newValue }
  }
}

これで安全にラップされたプロパティを持つインスタンスへのアクセスができるようになりますね!

また、副次的な効果として、static subscript<OuterSelf: Observed>(instanceSelf:wrapped:storage:) は展開されると self を引数にとるため、 static プロパティに対して適用できません。
また、現状 ReferenceWritableKeyPath を引数にとるため、 class 以外に適用できません(たぶん)。

なので、もし Property Wrapper 型を static プロパティや class 以外に適用されて欲しくない場合は、以下のように書けます。

@availability(*, unavailable) 
var wrappedValue: Value {
  get { fatalError("only works on instance properties of classes") }
  set { fatalError("only works on instance properties of classes") }
}

たまにラップされたプロパティを持つインスタンスにアクセスしたくなるときがあるので、ぜひ実装されて欲しいです。

他のプロパティに移譲する

現状、 @SomeWrapper を宣言すると暗黙的に _ がついたバッキングフィールドが追加されます。
ただ、もう既にあるプロパティをバッキングフィールドに使いたい場合があるかもしれません。

そのときに以下のように指定できるようにしようぜという提案。

lazy var fooBacking: SomeWrapper<Int>
@wrapper(to: fooBacking) var foo: Int

上の例では直接プロパティを入れていますが、 KeyPath のように \.someProperty.someOtherProperty のようにも指定できるようにするかなども考えられています。


以上、軽くですが Property Wrapper について解説しました。
Property Wrapper は他のプログラミング言語ではあまり見かけない、しかしテンプレート的な記述を減らせる可能性を秘めていると思います。

もしプロジェクトでバッキングフィールドを駆使しているプロパティがあったなら、それは Property Wrapper に置き直せるかもしれません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift5] 動画撮影方向の切替

メモです。

func setup() {
   // 端末の回転時に通知してもらう
   let action = #selector(orientationDidChange(_:))
   let center = NotificationCenter.default
   let name = UIDevice.orientationDidChangeNotification
   center.addObserver(self, selector: action, name: name, object: nil)
}

/// 端末が回転したときに呼出されるメソッド
@objc func orientationDidChange(_ notification: NSNotification) {

    // 現在の端末方向を取得
    let deviceOrientation: UIDeviceOrientation!  = UIDevice.current.orientation

    // VideoLayerのサイズを現在の画面サイズに合わせる
    // これしないと、画面が最初に設定したものと同じ状態のまま方向が変わってしまう。
    videoLayer.frame = self.view.bounds

    // 画面の方向に合わせて映像を調整
    // UIDeviceOrientation自体がenumだったのでそのままswitchで切り替える
    // - 今回は縦(ホームボタンが上)の状態は利用しなかったので記載していない
    // 利用する場合はportraitUpsideDownでcaseすればよい
    // - あとはlandscape時にvideoOrientationに入れるときは
    // deviceOrientationと逆のを入れないと逆さになるので注意
    switch deviceOrientation {
    case .portrait: // 縦(ホームボタンが下)
        videoLayer.connection?.videoOrientation = .portrait
    case .landscapeLeft: // 横(ホームボタンが右)※頭が左側に回転
        videoLayer.connection?.videoOrientation = .landscapeRight // 画面右側を頭に
    case .landscapeRight: // 横(ホームボタンが左)※頭が右側に回転
        videoLayer.connection?.videoOrientation = .landscapeLeft // 画面左側を頭に
    default: break
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUI UITextField  キャレット動作

こんにちはフリーランスの永田です。最近は法人化の手続きを開始しました。

SwiftUI案件を1月から実施予定で、現在技術を調査中です。

今回はキャレット動作 returnButtonを押下しましたら、水平移動する対応です。

環境

Xcode 11.3
SwiftUI

SwiftUIではない場合(オリジナルです。)

https://gist.github.com/daisukenagata/253ae79692234dbf89d042f5010f2387

キャレット動作しない場合

https://gist.github.com/daisukenagata/5002c49061f18d72e0a40dfda1290b1a

キャレット動作

https://gist.github.com/daisukenagata/253ae79692234dbf89d042f5010f2387

import UIKit
import SwiftUI

struct ContentView: View {

    @State var text: String = ""

    @State var text2: String = ""

    @State var spacing: CGFloat = 0

    @State var didTap  = false

    var body: some View {
        VStack {
            HStack(alignment: .bottom, spacing: spacing) {

                SATextField(tag: 0, placeholder: "placeholder", changeHandler: { (newString) in
                    self.text = newString
                }, onCommitHandler: {
                    // write something
                })
                self.text.isEmpty == false ?
                    HorizontalLine(color: self.didTap ? Color.red : Color.black) :
                    HorizontalLine(color: self.didTap ? Color.black : Color.red)

                SATextField(tag: 1, placeholder: "placeholder2", changeHandler: { (newString) in
                    self.text2 = newString
                }, onCommitHandler: {
                    // write something
                })
                text2.isEmpty == false ?
                    HorizontalLine(color: didTap ? Color.red : Color.black) :
                    HorizontalLine(color: didTap ? Color.black : Color.red)
            }
        }.position(.init(x: UIScreen.main.bounds.width/2+50, y: UIScreen.main.bounds.height/2))
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct HorizontalLine: View {

    private var color: Color? = nil
    private var height: CGFloat = 1.0
    private var shape: HorizontalLineShape?

    init(color: Color, height: CGFloat = 1.0) {
        self.color = color
        self.height = height
    }

    var body: some View {
        HorizontalLineShape().fill(self.color!).frame(minWidth: 0, maxWidth: .infinity, minHeight: height, maxHeight: height)
    }
}

struct HorizontalLineShape: Shape {

    func path(in rect: CGRect) -> Path {

        let fill = CGRect(x: -rect.size.width, y: 0, width: rect.size.width, height: rect.size.height)
        var path = Path()
        path.addRoundedRect(in: fill, cornerSize: CGSize(width: 2, height: 2))

        return path
    }
}

class Model: ObservableObject {
    @Published var text = ""
    var placeholder = "Placeholder"
}
// check is this
// https://medium.com/@valv0/textfield-and-uiviewrepresentable-46a8d3ec48e2
struct SATextField: UIViewRepresentable {
    private let tmpView = WrappableTextField()

    //var exposed to SwiftUI object init
    var tag:Int = 0
    var placeholder:String?
    var changeHandler:((String)->Void)?
    var onCommitHandler:(()->Void)?

    func makeUIView(context: UIViewRepresentableContext<SATextField>) -> WrappableTextField {
        tmpView.tag = tag
        tmpView.delegate = tmpView
        tmpView.placeholder = placeholder
        tmpView.onCommitHandler = onCommitHandler
        tmpView.textFieldChangedHandler = changeHandler
        return tmpView
    }

    func updateUIView(_ uiView: WrappableTextField, context: UIViewRepresentableContext<SATextField>) {
        uiView.setContentHuggingPriority(.defaultHigh, for: .vertical)
        uiView.setContentHuggingPriority(.defaultLow, for: .horizontal)
    }
}

class WrappableTextField: UITextField, UITextFieldDelegate {
    var textFieldChangedHandler: ((String)->Void)?
    var onCommitHandler: (()->Void)?

    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        if let nextField = textField.superview?.superview?.viewWithTag(textField.tag + 1) as? UITextField {
            nextField.becomeFirstResponder()
        } else {
            textField.resignFirstResponder()
        }
        return false
    }

    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        if let currentValue = textField.text as NSString? {
            let proposedValue = currentValue.replacingCharacters(in: range, with: string)
            textFieldChangedHandler?(proposedValue as String)
        }
        return true
    }

    func textFieldDidEndEditing(_ textField: UITextField) {
        onCommitHandler?()
    }
}

 

参考サイト

こちらのロジックを拝借させていただきました。ロジックの部分は、今まで通りのプログラムになります。

https://medium.com/@valv0/textfield-and-uiviewrepresentable-46a8d3ec48e2

プログラムがわかる場合は
changeHandlerを追えば、すぐにわかると思います

起動時に

makeUIViewメソッドでtmpView.textFieldChangedHandler = changeHandler
を代入します。
既存のUITextFieldメソッドで実装しています。onCommitHandlerもbind処理をしていますが、
Flowは同じです。

このメソッドは文字が1文字づつ変化する事に処理が行われます。 textFieldChangedHandler?(proposedValue as String)でbindしてchangeHandlerが呼ばれています。

 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        if let currentValue = textField.text as NSString? {
            let proposedValue = currentValue.replacingCharacters(in: range, with: string)
            textFieldChangedHandler?(proposedValue as String)
        }
        return true
    }

return Buttonを押下時 これでキャレットnextField.becomeFirstResponder()
UITextFieldのキーボードを開くメソッドです。

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        if let nextField = textField.superview?.superview?.viewWithTag(textField.tag + 1) as? UITextField {
            nextField.becomeFirstResponder()
        } else {
            textField.resignFirstResponder()
        }
        return false
    }

以上、とても簡単に解説しました
貴重なお時間お読みくださいまして、誠にありがとうございます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

レガシーアプリのiOS13対応(UISegmentedControl編

はじめに

UISegmentedControl編とタイトルには記載してますが、
続編等は今の所は全くありませんwww

さて、私が普段担当しているアプリは、業務で利用するInHouseの古いアプリなんですが
今年もiOSのバージョンアップ対応を毎度のごとく行っておりました。

UISegmentControlに関しても、今年は大幅に見た目が変わると知っていたので
TintColorの設定をして、アプリに合う見た目にして対応する事で十分だろうと思っていました。

先方にも、見た目がiOS13から変更された旨を伝え、一度はOKをもらっていましたが

やっぱり、前の見た目に戻してくれないか?

オォォーーー!! w(゚ロ゚;w(゚ロ゚)w;゚ロ゚)w オォォーーー!!

AppleがiOS13で変更した見た目に対して
iOS12以前の状態に戻していくことは
Appleに対しての反逆行為やぞーーーー(´;ω;`)(勝手に思っているだけ?)

できれば今のiOS13の見た目のままでカスタマイズしたかったのですが、、、
NGが出てしまったのでは仕方ない。iOS12以前の見た目にするしかない。。できるのか。。。

結果的に言えば 可能 でした!!
比較すれば元々のiOS12以前のモノと若干違うのは分かりますが、全然許容範囲!

実際のイメージをどうぞ!!

Simulator Screen Shot - iPhone 8 - 2019-12-17 at 23.42.27.png

ViewController.swift
    override func viewDidLoad() {
        super.viewDidLoad()
        if #available(iOS 13.0, *) {
            let segSizeSingle = CGSize.init(width: self.segment.frame.size.width, height: self.segment.frame.size.height)
            self.segment.setBackgroundImage(self.makeImage(UIColor.clear, size: segSizeSingle), for: .normal, barMetrics: .default)
            self.segment.setBackgroundImage(self.makeImage(self.segment.tintColor, size: segSizeSingle), for: .selected, barMetrics: .default)
            self.segment.setTitleTextAttributes([.foregroundColor : UIColor.white], for: .selected)
            self.segment.setTitleTextAttributes([.foregroundColor : self.segment.tintColor!], for: .normal)
            self.segment.layer.borderColor = self.segment.tintColor.cgColor
            self.segment.layer.borderWidth = 1
        }
    }

    func makeImage(_ color : UIColor, size : CGSize) -> UIImage {
        UIGraphicsBeginImageContext(size)
        let rect = CGRect(origin: CGPoint.zero, size: size)
        let context = UIGraphicsGetCurrentContext()!
        context.setFillColor(color.cgColor)
        context.fill(rect)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image!
    }

セグメントのBGImageや、レイヤー等の見た目を色々いじって実現しました!

今回のサンプルではViewDidLoadに記載しましたが
実際に実装する場合はExtentionやObjective-CだとCategoryで実装すると
アプリ全体で見た目を合わす事も簡単にできると思いますので

是非、ご活用ください!!!!

(そんな古い見た目に変える要望ねーよ!とかは言わないで…)

最後までご覧いただき、ありがとうございました!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

R.swiftとSwiftGenの導入方法とどちらを採用した方がいいのか

CA Tech Dojo/Challenge/JOB Advent Calendar 2019の18日目は@ostk0069が書かせていただきます。
次の日、19日目は@hmarfさんです!楽しみにしてます!
自分は、2019年8月にCATechDojo(Kotlin編)に参加させていただいた後、11月にCATechJOBでマッチングエージェントさんでiOSエンジニアとしてインターンをさせていただきました。大変お世話になりました。

はじめに

私は現在進行形で個人アプリの開発をしています。その際に初めはR.swiftを導入していたのですが、途中からSwiftGenへ移行したのでそこでわかった、互いの良い面、悪い面について触れていければと思います。

R.swift、SwiftGenの話はこの記事を見たら理解が十分な状態に仕上げられていると思うのでよかったら最後まで読んでいただけるとありがたいです。

R.swift、SwiftGenとは

これらは主に文字列管理のしやすさのために使用するライブラリです。
文字列管理で一番最初に思い浮かべるのは多言語化対応かと思いますが、それだけではありません。
多言語化対応しないアプリでも導入する価値は十分にあります。UIImageUIColorの指定もコードでtypeSafeで利用することができるので利便性があります。image literalcolor literalで指定したものは次に開いたときには何を指定していたかわからなくなるのでそれが気に入らない方はコードで指定している分、管理しやすいとも言えます。

結論

結論から言うと、R.swiftSwiftGen、どちらを導入するか迷っている方で少なくともSwiftGenを導入することを大きなコストと捉えていない方はSwiftGenを導入する方がいいのではという気持ちです。

その理由も含めて、まずは導入方法から色々と説明していこうと思います。

このようなフローで説明していきます。
(飛ばしたい方はこちらから選択して見て行って下さい)

本来であれば比較とどっちを選ぶべきであるかのみでもいいかなと思ったのですが、
導入方法について、紹介したいTipsがあったので導入方法も追加することにしました!

R.swift

導入方法

GitHubに記載されているドキュメントを参照するとCocoaPodsでの導入が推奨されているのでそちらでの方法で説明していきたいと思います。

ここではCocoaPods自体の導入方法は説明しないのでわからない方はこちらからどうぞ。

Podfileへ以下の一行を追加します。

Podfile
pod 'R.swift'

その後、Terminal上で以下のコマンドを叩きます。

pod install

この後、ドキュメントではNew Run Script Phaseを選択したのち、"$PODS_ROOT/R.swift/rswift" generate "$SRCROOT/R.generated.swift"を追加するように書いてあります。

しかし、この記述方法はあまりオススメしません。自動生成されるファイルのR.generated.swiftが自分のプロジェクトファイルのレポジトリ直下に生成されてしまうからです。GitHubでソースコードを管理している人は特にレポジトリ直下に.swiftのファイルが存在するのは結構違和感があるのではないかと思います。なのでNew Run Script Phaseを選択したのち、以下を追加しましょう。

"$PODS_ROOT/R.swift/rswift" generate "$SRCROOT/"プロジェクトファイル"/"hoge"/"fuga"/R.generated.swift"
# プロジェクトファイル以下は自分の好きな場所に追加すればいいと思います

その後、アプリをビルドすることでR.generated.swiftのファイルが上記で指定したディレクトリに自動生成されます。自動生成されたファイルは.projectへの追加は自動でされないので、手動で追加してあげましょう。

その後、自動生成ファイルをgitで管理する意味はないので

.gitignore
*.generated.swift

を追加してあげることでひとまず導入は終了となります。

記述方法のサンプル

これはドキュメントから引っ張ってきたものなんですが、

通常で書く場合

let icon = UIImage(named: "settings-icon")
let font = UIFont(name: "San Francisco", size: 42)
let color = UIColor(named: "indicator highlight")
let viewController = CustomViewController(nibName: "CustomView", bundle: nil)
let string = String(format: NSLocalizedString("welcome.withName"))

R.swiftで記述した場合

let icon = R.image.settingsIcon()
let font = R.font.sanFrancisco(size: 42)
let color = R.color.indicatorHighlight()
let viewController = CustomViewController(nib: R.nib.customView)
let string = R.string.localizable.welcomeWithName("Arthur Dent")

とこのような違いがあります。すべてR.が始まる共通点がありますね。

SwiftGen

導入方法

R.swiftに対して導入はSwiftGenの方が少し複雑です。
まず初めに導入方法を選ぶ必要がある訳ですが、今回はR.swiftのときにCocoaPodsで導入の説明を行ったので揃えた方が違いを理解しやすいと思うのでCocoaPodsを使った場合での説明を行っていこうと思います。

Podfileへ以下の一行を追加します。

Podfile
pod 'SwiftGen'

その後、Terminal上で以下のコマンドを叩きます。

pod install

次に設定ファイルを作成します。このファイルの設定を記述するのがこの導入を難しくしています。

touch swiftgen.yml

作成したswiftgen.ymlを編集していく訳ですが、まず初めにドキュメントに書かれているサンプルを見ていきましょう

swiftgen.yml
strings:
  inputs: Resources/Base.lproj
  filter: .+\.strings$
  outputs:
    - templateName: structured-swift4
      output: Generated/strings.swift
xcassets:
  inputs:
    - Resources/Images.xcassets
    - Resources/MoreImages.xcassets
  outputs:
    - templateName: swift4
      output: Generated/assets-images.swift

これがどのような設定をしているのか説明していきます。

このサンプルでは

  • Base.lprojのファイルからの生成
  • .xcassetsのファイルからの生成

を行っています。もちろん、SwiftGenではそれ以外のStoryboardとかも生成できる訳ですが、それは別途記述が必要です。

さらに具体的にどのように書いているか見ていくと、
inputsoutputsが定義されていて

  • inputsは生成したいファイルの指定
  • outputsは生成したもの出力先

気になるのはfiltertemplateNameというものが存在することです。

filterとは

本来であればinputsの中のファイルの指定はBase.lprojではなく、Base.lproj/Localizable.stringsである方が自然です。しかし、多言語化対応をしているとおそらくLocalizable.stringsは対応している言語分あるはずなので指定が面倒なのでfilter: .+\.strings$と記述してあげることですべてのLocalizable.stringsをinputsの対象に含めることができます

templateNameとは

SwiftGenのなかで指定することのできるtemplateNameは以下です。

  • structured-swift4
  • flat-swift4
  • swift4
  • structured-swift3
  • flat-swift3
  • swift3

があります。現在Swift5で開発している人が多いからswift5は?って思う人もいるかもしれませんが現時点では存在しません(swift4->swift5でそんなに影響を受けてないからだと思います)

stringの指定をするときにはswift4swift3の指定は行えないので注意してください。
flatstructuredを選択するときは構造的にしたいかしたくないかで選びましょう。stringの場合、多言語化対応が多いと思うのでstructuredを推奨します。

この選択肢以外にも自分でよりカスタマイズしたい方は別の方法が存在します。
それはStencilを使うことで実現可能です。元々上記のstructured-swift4flat-swift4Stencilを使って作成されたテンプレートです。気になった方はぜひ見てみてください。(この記事ではこの内容にこれ以上Stencilについては触れません)

記述方法のサンプル

通常で書く場合

let icon = UIImage(asset: Asset.Exotic.icon) 
let displayRegular = UIFont(font: FontFamily.SanFrancisco.regular, size: 42.0)
let title = UIColor(named: .articleBody)
let string = String(format: NSLocalizedString("welcome.with_name"))

SwiftGenで記述した場合

let icon = Asset.Exotic.banana.image
let displayRegular = FontFamily.SFNSDisplay.regular.font(size: 42.0)
let title = ColorName.articleBody.color
let message = L10n.Welcome.withName

R.swiftとSwiftGenの比較

特徴を並べていきます。

R.swift
- 導入が楽
- リソースが単一
- buildするタイミングで毎回自動生成が走る

SwiftGen
- swiftgen.ymlファイルでカスタマイズできるので拡張性がある
- リソースが複数
- 差分を見て生成するかどうかを見てくれる

今回この記事を書くきっかけにもなったのですが、私はどちらも週数間の運用をしました。
その中でSwiftGenの方がいいと思ったので途中で乗り換えました
理由としては

  • UIViewR.stringSwiftGenで管理する理由がそこまでないので、網羅性のあるR.swiftの強みが生きなかった
  • メインで使うlocalizeStringが長いので画面名とか役割とかを付け足すとR.swiftだと長くなってしまう
    • R.swiftだと: R.string.localizable.hoge
    • SwiftGenだと: L10n.hoge
  • 設定する側をR.swiftだとスネークケースで書くとスネークケースのままで生成されてしまうが、SwiftGenだとスネークケースで書いたものをキャメルケースで生成してくれる
  • 末尾に()がつくかどうか
    • コードを書いている時の保管として()なし()(Void)の3つの選択肢が出てくるのでめんどい

の4つが主なものです。

僕は、特に中2つを利用したいがためにSwiftGenを採用しました。特にlocalizeStringの長さの話はすごく重要だと思っています。
良くあるケースとして{ViewControllerの名前}.{UIViewの名前}.{enumの名前}.titleとかを指定した場合は可読性が下がりやすいのでなるべく呼び出すときのデフォルトのクラス名は少ない方が嬉しいです。
また、スネークケース、キャメルケースの話も、長く運用する上では大事だと思っています。Localizable.Stringsでキャメルケースを書くのは自分はあまり好みには思えませんでした。
これらは実際にどっちも運用したことがないと気づけない話なので参考になれば嬉しいです。

一番最初に言った、どちらを導入するか迷っている方でSwiftGenを導入することを大きなコストと捉えていない方はSwiftGenを導入するべきはこれらが理由です。R.swiftは導入しやすい分、で細かいところに届きにくい設計になってしまいます。長期的な運用を考えるとそこのリスクはとるのは難しいかもしれません。

逆に言えば、これらをメリットと感じないのであればR.swiftを使う方がいいと言えます。

どっちを選ぶべきなのか

最終的には当たり前ですが、好きな方を選べばいいという話に落ち着きます。しかし、この記事を見ている方は長期運用を想定した上で文字列管理をしようとしている方が多いのではないでしょうか。めんどくさい作業が発生するので安易に乗り換えることはできないので将来性を加味して選ぶ必要があると思います(経験談)

参考資料

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む