20200119のiOSに関する記事は9件です。

【Swift】UICollectionViewCompositionalLayoutを使って数種類のカスタムレイアウトを実装してみる

はじめに

WWDC2019で新たに発表された、UICollectionViewのレイアウト手法であるUICollectionViewCompositionalLayoutを使っていくつかカスタムレイアウトを作ってみました。
※ iOS13以降の環境にて、今回作成したサンプルを動かせます

UICollectionViewCompositionalLayoutとは

詳細については、WWDCのセッションを参考にして頂ければと思いますが、ざっくり言うと、

  • iOS13からUICollectionViewCompositionalLayoutが登場したことで、UICollectionViewFlowLayoutUICollectionViewDelegateFlowLayoutに加えて、UICollectionViewのレイアウトを定義する方法が1つ増えた。
  • iOS13からUICollectionViewDiffableDataSourceが登場したことで、UICollectionViewDataSourceを準拠して行なっていたDataSource管理の方法が増えた。(こちらについては本記事では解説していません)

※ UICollectionViewDataSourceのみでDataSource管理を行なった場合でも、UICollectionViewCompositionalLayoutは使用できます。

※ より詳細な情報については、こちらの記事がかなり詳しくまとめてくださっています。
https://qiita.com/shiz/items/a6032543a237bf2e1d19

階層構造

UICollectionViewCompositionalLayoutにおける階層構造は、Item、Group、Sectionなどの概念から成り立っています。

スクリーンショット 2020-01-19 20.38.31.png

実装するレイアウト

今回はUICollectionViewCompositionalLayoutを使用して、3種類のレイアウトを実装してみました。
スクリーンショット 2020-01-19 20.43.22.png

1. グリッド形式(3×n)

スクリーンショット 2020-01-19 20.58.28.png

このレイアウトは、赤枠で囲んだ部分のグループを用意し、×nの形式で表示させる属性を持たせたセクションを用意します。

SectionType.swift
private func gridSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let itemCount = 3 // 横に並べる数
        let lineCount = itemCount - 1
        let itemSpacing = CGFloat(1) // セル間のスペース
        let itemLength = (collectionViewBounds.width - (itemSpacing * CGFloat(lineCount))) / CGFloat(itemCount)
        // 1つのitemを生成
        // .absoluteは固定値で指定する方法
        let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(itemLength),
                                                                             heightDimension: .absolute(itemLength)))
        // itemを3つ横並びにしたグループを生成
        // .fractional~は親Viewとの割合
        let items = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                          heightDimension: .fractionalHeight(1.0)),
                                                       subitem: item,
                                                       count: itemCount)
        // グループ内のitem間のスペースを設定
        items.interItemSpacing = .fixed(itemSpacing)

        // 生成したグループ(items)が縦に並んでいくグループを生成(実質これがセクション)
        let groups = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                         heightDimension: .absolute(itemLength)),
                                                      subitems: [items])
        // 用意したグループを基にセクションを生成
        // 基本的にセルの数は意識しない、セルが入る構成(セクション)を用意しておくだけで勝手に流れてく
        let section = NSCollectionLayoutSection(group: groups)

        // セクション間のスペースを設定
        section.interGroupSpacing = itemSpacing
        return section
    }

必要なセクション(NSCollectionLayoutSection)を用意したら、それを基にUICollectionViewCompositionalLayoutを生成できます。

UICollectionViewCompositionalLayout(section: 生成したSection)

あとは用意したUICollectionViewCompositionalLayoutクラスをCollectionViewに割り当てます。

ViewController.swift
collectionView.collectionViewLayout = 用意したUICollectionViewCompositionalLayout
// 既に構成されているレイアウトを更新する場合は、invalidateLayout()を呼びます。
collectionView.collectionViewLayout.invalidateLayout()

CompositionalLayout-Grid.gif

2. 異なるサイズのItem併用 (Instagram風)

スクリーンショット 2020-01-19 20.58.41.png

このレイアウトは、緑枠で囲った各グループを用意して、それらを結合した1つのセクションを用意します。

SectionType.swift
private func largeAndSmallSquareSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let itemSpacing = CGFloat(2) // セル間のスペース

        // 小itemが縦に2つ並んだグループ
        let itemLength = (collectionViewBounds.width - (itemSpacing * 2)) / 3
        let largeItemLength = itemLength * 2 + itemSpacing

        let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(itemLength),
                                                                             heightDimension: .absolute(itemLength)))
        let verticalItemTwo = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(itemLength),
                                                                                                  heightDimension: .absolute(largeItemLength)),
                                                               subitem: item,
                                                               count: 2)
        verticalItemTwo.interItemSpacing = .fixed(itemSpacing)

        // 大item + 小item*2 のグループ
        let largeItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(largeItemLength),
                                                                                  heightDimension: .absolute(largeItemLength)))

        let largeItemLeftGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                                       heightDimension: .absolute(largeItemLength)),
                                                                    subitems: [largeItem, verticalItemTwo])
        largeItemLeftGroup.interItemSpacing = .fixed(itemSpacing)

        let largeItemRightGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                                        heightDimension: .absolute(largeItemLength)),
                                                                     subitems: [verticalItemTwo, largeItem])
        largeItemRightGroup.interItemSpacing = .fixed(itemSpacing)

        // 小ブロックが縦に2つ並んだグループを横に3つ並べたグループ
        let twoThreeItemGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                                      heightDimension: .absolute(largeItemLength)),
                                                                   subitem: verticalItemTwo,
                                                                   count: 3)
        twoThreeItemGroup.interItemSpacing = .fixed(itemSpacing)

        // 各グループを縦に並べたグループ
        let subitems = [largeItemLeftGroup, twoThreeItemGroup, largeItemRightGroup, twoThreeItemGroup]
        let groupsSpaceCount = CGFloat(subitems.count - 1)
        let heightDimension = NSCollectionLayoutDimension.absolute(largeItemLength * CGFloat(subitems.count) + (itemSpacing * groupsSpaceCount))
        // MEMO: 高さの計算は後に追加するスペース分も足す
        let groups = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                         heightDimension: heightDimension),
                                                      subitems: subitems)
        groups.interItemSpacing = .fixed(itemSpacing)
        let section = NSCollectionLayoutSection(group: groups)
        section.interGroupSpacing = itemSpacing
        return section
    }

同様に、セクションを基にUICollectionViewCompositionalLayoutを用意してCollectionViewのレイアウトに割り当てます。

CompositionalLayout-Instagram.gif

3. 複数Section併用 (Netflix風)

スクリーンショット 2020-01-19 20.58.56.png

Appleが公式で公開しているサンプルをみた感じ、「ヘッダーを付与する」や「itemを横スクロールさせる」といった実装は、セクション(NSCollectionLayoutSection)を対象に振る舞いを指定する方法が一般的なようなので、上記レイアウトを実装する場合は、「複数のセクションを用意して実行時に渡ってくるindexPath.sectionの値に合わせて適切なセクションを返す」という流れで実装してみました。

公式サンプル
https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/using_collection_view_compositional_layouts_and_diffable_data_sources

SectionType.swift
/// 縦長の長方形が1つだけのセクション
    private func verticalRectangleSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let verticalRectangleHeight = collectionViewBounds.height * 0.7
        let verticalRectangleItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                              heightDimension: .fractionalHeight(1.0)))
        let verticalRectangleGroup = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                                         heightDimension: .absolute(verticalRectangleHeight)),
                                                                      subitem: verticalRectangleItem,
                                                                      count: 1)
        return NSCollectionLayoutSection(group: verticalRectangleGroup)
    }

/// 縦長の長方形が横スクロールするセクション(ヘッダー付き)
    private func rectangleHorizonContinuousWithHeaderSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let headerHeight = CGFloat(50)
        let headerElementKind = "header-element-kind"
        let insetSpacing = CGFloat(5)
        let rectangleItemWidth = collectionViewBounds.width * 0.9 / 3
        let rectangleItemHeight = rectangleItemWidth * (4/3)
        let rectangleItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                      heightDimension: .fractionalHeight(1.0)))
        let horizonRectangleGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(rectangleItemWidth),
                                                                                                          heightDimension: .absolute(rectangleItemHeight)),
                                                                       subitem: rectangleItem,
                                                                       count: 1)
        horizonRectangleGroup.contentInsets = NSDirectionalEdgeInsets(top: insetSpacing, leading: insetSpacing, bottom: insetSpacing, trailing: insetSpacing)
        let horizonRectangleContinuousSection = NSCollectionLayoutSection(group: horizonRectangleGroup)
        let sectionHeaderItem = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .absolute(headerHeight)),
            elementKind: headerElementKind,
            alignment: .top)
        sectionHeaderItem.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: insetSpacing, bottom: 0, trailing: insetSpacing)
        horizonRectangleContinuousSection.boundarySupplementaryItems = [sectionHeaderItem] // セクションに対してヘッダーを付与
        horizonRectangleContinuousSection.orthogonalScrollingBehavior = .continuous // セクションに対して横スクロール属性を付与
        horizonRectangleContinuousSection.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: insetSpacing, bottom: 0, trailing: insetSpacing)
        return horizonRectangleContinuousSection
    }

/// 正方形が1つだけのセクション(ヘッダー付き)
    private func squareWithHeaderSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let itemLength = collectionViewBounds.width
        let headerHeight = CGFloat(50)
        let headerInsetSpacing = CGFloat(10)
        let headerElementKind = "header-element-kind"
        let squareItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))
        let squareGroup = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                              heightDimension: .absolute(itemLength)),
                                                           subitem: squareItem,
                                                           count: 1)
        let squareSection = NSCollectionLayoutSection(group: squareGroup)
        let sectionHeaderItem = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .absolute(headerHeight)),
            elementKind: headerElementKind,
            alignment: .top)
        sectionHeaderItem.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: headerInsetSpacing, bottom: headerInsetSpacing, trailing: 0)
        squareSection.boundarySupplementaryItems = [sectionHeaderItem]
        return squareSection
    }

用意した3種類のセクションをindexPath.sectionに合わせて返します。
※ CollectionView側のセクション数は別途指定しています。(今回は4)

各セクションをindexPath.sectionに合わせて返す場合は、UICollectionViewCompositionalLayoutのinit時に以下のイニシャライザを使います。

UICollectionViewCompositionalLayout.h
 public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)
 public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider, configuration: UICollectionViewCompositionalLayoutConfiguration)

実装例

let layout = UICollectionViewCompositionalLayout { (section, _) -> NSCollectionLayoutSection? in
            let layoutSection = 渡ってきたsectionに応じたNSCollectionLayoutSectionを返す
            return layoutSection
        }
return layout

上記の流れで、用意した3種類のセクションを4つのsectionに対してそれぞれ適したものを返すことで、レイアウトを組みます。
今回のsectionに対するセクション順は以下。

0: 縦長の長方形が1つだけのセクション
1: 縦長の長方形が横スクロールするセクション(ヘッダー付き)
2: 縦長の長方形が横スクロールするセクション(ヘッダー付き)
3: 正方形が1つだけのセクション(ヘッダー付き)

※ 「縦長の長方形が1つだけのセクション」は、Netflix風を意識して、他のセルと違うセルクラスを使用しています)

CompositionalLayout-Netflix.gif

ソースコード

今回作成したサンプルのソースは以下のリポジトリにあります。
https://github.com/ddd503/CompositionalLayouts-Sample

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

FirebaseのRemoteConfigを使ってキャンペーンのようなモーダルを表示させたい[RemoteConfig応用編]

最初に

このQiita記事は、RemoteConfig初級編の応用編として書かれているため、RemoteConfig初級編がまだの方はこちらからお読みください。

やりたいこと

応用編では、初級編でやったことを用いてLabelと画像の値を変更してモーダルで表示することをします。
完成形の画面はこんな感じだよ!

RemoteConfigの設定

画像を参考にしながら、FirebaseのRemoteConfigの画面から、任意のパラメータキー(card_key)デフォルト値に以下のjsonを入力します。

RemoteConfig
{
  "title": "企画のタイトル!!!",
  "image_url": "https://ocadweb.com/wp-content/uploads/2016/03/32.png",
  "details": "ここには、企画等の詳細情報を記入してください。",
  "url": "https://picable.co.jp",
  "identifier": "20200112"
}

storyboardファイルで必要なパーツを配置しよう。

storyboardファイルを生成して、StoryboardIDに"CardRemoteConfigViewController"と記入します。
デザインは、以下の画像を参考にして配置してみてください。
タイトル、画像、内容のテキスト、アンケート等に遷移させるボタン、閉じるボタンを配置しています。
背景は、黒の透明度60%
画像:今回は、300×200の比率の画像を利用しています。

スクリーンショット 2020-01-19 19.38.05.png

コーディング

はじめに、RemoteConfigからjsonのデータを受け取るEntityを作ります。

CardRemomteConfigEntity.swift
struct CardRemomteConfigEntity: Codable {
    let title: String
    let url: String
    let details: String
    let imageURL: String?
    let identifier: String

    enum CodingKeys: String, CodingKey {
        case title
        case url
        case details
        case imageURL = "image_url"
        case identifier
    }
}

RemoteConfigManagerに新しいパラメータキーとjsonを扱う変数を追加します。

RemoteConfigManager.swift
import FirebaseRemoteConfig

class RemoteConfigManager {
    //以下のキーを追加
    private let cardKey = "card_key"
    //以下の2行を追加
    private let jsonDecoder = JSONDecoder()
    private var defaultValue: [String: NSObject] = [:]

RemoteConfigManagerのupdateVariables()関数でCardRemomteConfigEntityを変数にセットします。

RemoteConfigManager.swift
    private func updateVariables() {
       //以下のコードを追加
       cardRemoteConfig = (try? jsonDecoder.decode(CardRemomteConfigEntity.self, from: remoteConfig[cardKey].dataValue))
    }

CardRemoteConfigViewController.swiftを新しくファイルを作成して以下のコードを記述してください。
やっていることは、モーダルに表示したいパーツとIBOutletと繋いで、初級編と同じように、RemoteConfigManagerを呼び出して必要に応じてラベルや画像に代入しています。

CardRemoteConfigViewController.swift
import UIKit

class CardRemoteConfigViewController: UIViewController {
    @IBOutlet private weak var titleTextLabel: UILabel!
    @IBOutlet private weak var bodyTextView: UITextView!
    @IBOutlet private weak var presentWebButton: UIButton!
    @IBOutlet private weak var closeButton: UIButton!
    @IBOutlet private weak var cardImage: UIImageView!
    @IBOutlet private weak var modalView: UIView!

    func setup() {
        modalTransitionStyle = .crossDissolve
        modalPresentationStyle = .overCurrentContext
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        modalView.layer.cornerRadius = 8
        titleTextLabel.text = RemoteConfigManager.shared.cardRemoteConfig?.title
        bodyTextView.text = RemoteConfigManager.shared.cardRemoteConfig?.details
        if let url = RemoteConfigManager.shared.cardRemoteConfig?.imageURL?.convertURL {
            do {
                let data = try Data(contentsOf: url)
                cardImage.image = UIImage(data: data)
             }catch let err {
                  print("Error : \(err.localizedDescription)")
             }
        }
    }


    @IBAction func closeButton(_ sender: Any) {
        self.dismiss(animated: true, completion: nil)
    }

    @IBAction func presentWebButton(_ sender: Any) {
        if let openUrl = RemoteConfigManager.shared.cardRemoteConfig?.url.convertURL{
            if UIApplication.shared.canOpenURL(openUrl) {
                UIApplication.shared.open(openUrl)
            }
        }
    }

}

extension String{
    var convertURL: URL? {
        return URL(string: self)
    }
}

ViewControllerには、上のコードで設定したモーダルを表示するコードを追加します。
注意⚠️ viewDidLoad()で記述するとうまく画面を表示させることができません。
タップした時に表示したい場合は、タップアクションの中で呼び出すようにしてください。
今回は、ViewControllerが呼び出されたタイミングで表示したいので、viewDidAppear()の中に記述しています。

CardRemoteConfigViewController.swift
override func viewDidAppear(_ animated: Bool) {
         super.viewDidAppear(animated)
        if RemoteConfigManager.shared.cardRemoteConfig?.title != nil{
            let storyboard: UIStoryboard = UIStoryboard.init(name: "CardRemoteConfigViewController", bundle: nil)
            let cardRemoteConfigViewController = storyboard.instantiateViewController(withIdentifier: "CardRemoteConfigViewController") as! CardRemoteConfigViewController
            cardRemoteConfigViewController.modalPresentationStyle = .fullScreen
            cardRemoteConfigViewController.setup()
            self.present(cardRemoteConfigViewController, animated: false, completion: nil)
        }
    }

⚠️実装におけるの問題点⚠️

今回の仕組みでは、アプリの初期起動時やRemoteConfigの値変更後のアプリ起動時には、変更した内容は次回起動時以降にUIに反映される仕様です。RemoteConfig変更直後の起動ではデータがうまく反映されません。初回起動時の場合は、データが蓄積されていないため、モーダルを表示させないように今回はさせていただきました。

コメント

今回初めてRemoteConfigを使用したQiita記事をさせていただきました。疑問に思ったことはわからないこと、間違っている箇所がある等があれば以下のコメント欄にてお知らせください。
キャンペーン等のモーダルを非表示にしたい場合は、remoteConfigのパラメーターキーとjsonを削除することで非表示にできます。

最後までお読みいただきありがとうございました。

サンプルコードをダウンロードしたい方はGithubをクリックしよう。

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

FirebaseのRemoteConfigを使ってLabelの値を変更しよう![RemoteConfig初級編]

開発環境

Xcode: Version 11.3
実機iPhone: iOS 13.1
Firebase: Version 6.14.0
FirebaseRemoteConfig: Version 4.4.6

やりたいこと

FirebaseのRemoteConfigを使ってLabelの値を変更したい。
初級編では、RemoteConfigを利用してViewControllerのLabelを変更してみようということで以下の画像のように、「Firebase 勉強中!!!」の文字を実際にRemoteConfigを利用して表示させていきます。

RemoteConfig応用編
応用編では、RemoteConfigを利用してモーダルの値を変更してモーダルで表示することをやります。
応用編はこちらをクリック!

完成形の画面はこんな感じだよ!

実装の準備

それでは実際に開発してみよう!
今回は、タイトル通りFirebaseを利用するので、XcodeのプロジェクトファイルにFirebaseのライブラリーをインストールしておきましょう!サンプルコードでも、Cocoa Podを利用してFirebaseをインストールする必要があります。Firebaseの設定時に、「GoogleService-Info.plist」をプロジェクトファイルに追加する必要があります。

Firebaseの初期導入がわからない方は、以下の記事を参考にしてください。
Firebaseの初期導入はこちらを参考に!

RemoteConfigの設定

画像を参考にしながら、FirebaseのRemoteConfigの画面から、任意のパラメータキー(この記事ではtitle_name)とLabelに表示する文字列を入力しよう。

コーディング

RemoteConfigManagerを作ります。
ここでは、先ほど設定したパラメータキーの名前を変数として代入する

RemoteConfigManager.swift
import FirebaseRemoteConfig

class RemoteConfigManager {
    static let shared = RemoteConfigManager()
    private let remoteConfig = RemoteConfig.remoteConfig()
    //初期の値
    var titleString: String = "まだデータがありません。"
    //パラメータキー
    private let titleKey = "title_name"

取得したデータを変数に代入

RemoteConfigManager.swift
    private func updateVariables() {
        //RemoteConfigから取得したデータを入れる。
        titleString = remoteConfig[titleKey].stringValue ?? ""
    }

インスタンス生成時にRemoteConfigからのデータをfetchする処理

RemoteConfigManager.swift
    private init() {
        remoteConfig.configSettings = RemoteConfigSettings()
        updateVariables()
        //開発環境の場合は0、本番環境(App Storeに配布する場合)では、は3600(1時間)以上に設定してください。
        let expirationDuration = 0
        remoteConfig.fetch(withExpirationDuration: TimeInterval(expirationDuration)) {
            [weak self] (status, _) in
            switch status {
            case .success:
                self?.remoteConfig.activate(completionHandler: { error in
                    if let error = error {
                        print("error:\(error)")
                    }
                    self?.updateVariables()
                })
            case .failure:
                print("error: remote config fetch failure")
            default: break
            }
        }
    }

最後にViewControlleを開いたタイミングで値をLabelに代入

ViewController.swift
class ViewController: UIViewController {

    @IBOutlet weak var titileLabel: UILabel!
    override func viewDidLoad() {
        super.viewDidLoad()
        titileLabel.text = RemoteConfigManager.shared.titleString

    }

⚠️実装におけるの問題点⚠️

今回の仕組みでは、アプリの初期起動時やRemoteConfigの値変更後のアプリ起動時には、変更した内容は次回起動時以降にUIに反映される仕様です。

RemoteConfig応用編

応用編では、画像を加えてキャンペーンのようなモーダルを表示させることを紹介します。
応用編はこちらをクリック!

サンプルコードをダウンロードしたい方はGithubをクリックしよう!

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

クリーンアーキテクチャーをざっと理解できる短いコードを書いた

クリーンアーキテクチャーは沢山インターフェースやクラスが登場し、その関係をざっと把握するのが難しいです。
そこですべての要素が入ったなるべく短いコードを書いてみました。XCodeのPlaygroundなので、そのまま実行できます。githubのリポジトリはこちらです。なお、今回はざっと理解することを優先するため、クリーンアーキテクチャーについての詳しい説明は割愛します。参考記事をご覧ください。

このコードは、Uncle Bobの書いたClean Architecture 達人に学ぶソフトウェアの構造と設計と、次の記事を参考に書いています。

参考記事:
実装クリーンアーキテクチャ: https://qiita.com/nrslib/items/a5f902c4defc83bd46b8
Laravelで実践クリーンアーキテクチャ: https://qiita.com/nrslib/items/aa49d10dd2bcb3110f22

クリーンアーキテクチャーの概要図

この2つの図が有名かと思います。
image.png

image.png
引用:Clean Architecture 達人に学ぶソフトウェアの構造と設計

概要図と今回のコードとの対応

サンプルコードに現れるインターフェース、クラスをなるべく本家の図と同じ位置に同じ色で配置しています。

image.png

image.png

この図で、本家と違うのがSomeViewUserControllerにも依存しているところです。理由は、本家はMVC1を前提としていますが、Webフレームワークなどで使われているMVC2に寄せたほうが理解がしやすいと判断したためです。このあたりは『クリーンアーキテクチャの Presenter が分かりにくいのは MVC 2 じゃないから』という記事に解説があります。

コード

実際のコードです。簡単な説明がコメント行に入っています。

クリーンアーキテクチャーのコード
// 【Frameworks & Drivers】アプリケーションフレームワークやドライバなど実装の詳細にあたる部分

// DB ORMやDAOなどDBとのやり取りをするAPI。このサンプルでは何もしない
class SomeDB {
    static func executeQuery(sql: String, bindParam: [String]) {
        // Dummy ここで実際にユーザーを登録する
    }
}

// UI UIKitやRailsの表示部分など画面表示をするためのAPI
class SomeView {
    var userController: UserController
    var viewModel: UserCreateViewModel

    init(userController: UserController, viewModel: UserCreateViewModel) {
        self.userController = userController
        self.viewModel = viewModel
        self.viewModel.bind { userName in
            print("登録:" + userName + "さん")
        }
    }

    func start(){
        userController.createUser(userName: "test user")
    }
}

// 【Interface Adapters】Application Business RulesとFrameworks & Driversの型の相互変換

// Controllers 入力をUserCaseのために変換する(入力のための変換)
class UserController {
    var userCreateUseCase: UserCreateUseCaseInputPort

    init(userCreateUseCase: UserCreateUseCaseInputPort) {
        self.userCreateUseCase = userCreateUseCase
    }

    func createUser(userName: String) {
        let input = UserCreateInputData(userName: userName)
        userCreateUseCase.handle(input: input)
    }
}

// GateWays Frameworks & Driversからのデータを抽象化する
class UserDataAccess: UserDataAccessInterface {
    func save(user: UserEntity) {
        SomeDB.executeQuery(
            sql: "REPLACE INTO USER (USER_NAME) VALUES (?) ",
            bindParam: [user.userName]
        )
    }
}

// Presenters データをViewに適した加工する(出力のための変換)
class UserCreatePresenter: UserCreateUseCaseOutputPort {
    var viewModel: UserCreateViewModel

    init(viewModel: UserCreateViewModel) {
        self.viewModel = viewModel
    }

    func complete(output: UserCreateOutputData) {
        let userName = output.userName
        self.viewModel.update(userName: userName)
    }
}

class UserCreateViewModel {
    typealias CallBackType = (String)->Void
    var userName: String
    var callBack: CallBackType?
    init(userName: String) {
        self.userName = userName
    }

    func bind(callBack: @escaping CallBackType) {
        self.callBack = callBack
    }

    func update(userName: String) {
        self.userName = userName
        self.callBack?(userName)
    }
}

// 【Application Business Rules】 アプリケーションのビジネスルール

// UseCaseと上位層との遣り取りをするためのオブジェクト
protocol UserDataAccessInterface {
    func save(user: UserEntity)
}

protocol UserCreateUseCaseOutputPort { // Output Boundaryともいう
    func complete(output: UserCreateOutputData)
}

struct UserCreateInputData {
    var userName: String
}

struct UserCreateOutputData {
    var userName: String
}

// Use Cases ユースケースを表す
protocol UserCreateUseCaseInputPort {  // Input Boundaryともいう
    func handle(input: UserCreateInputData)
}

class UserCreateInteractor: UserCreateUseCaseInputPort {
    var userDataAccess: UserDataAccess
    var presenter: UserCreateUseCaseOutputPort
    init(userDataAccess: UserDataAccess, presenter: UserCreateUseCaseOutputPort) {
        self.userDataAccess = userDataAccess
        self.presenter = presenter
    }

    func handle(input: UserCreateInputData) {
        let userName = input.userName

        let user = UserEntity(userName: userName)
        userDataAccess.save(user: user)

        let output = UserCreateOutputData(userName: user.userName)
        presenter.complete(output: output)
    }
}

// 【Enterprise Business Rules】 ドメイン層
// Entities ビジネスルールをカプセル化したもの
struct UserEntity {
    var userName: String
}

// Entry Point このサンプルの実行開始ポイント
let viewModel = UserCreateViewModel(userName: "")
let userDataAccess = UserDataAccess()
let presenter = UserCreatePresenter(viewModel: viewModel)
let useCase = UserCreateInteractor(userDataAccess: userDataAccess, presenter: presenter)
let userController = UserController(userCreateUseCase: useCase)
var ui = SomeView(userController: userController, viewModel: viewModel)

ui.start()

まとめ

短い、といいながら140行ほどになってしまいました。また、なるべく簡単にするためにuserNameだけを持つclassばかりとなってしまい、それぞれのclassの必要性がつかみにくくなってしまったのが残念です。

コードと図を書いていて気がついたのは、UseCaseが依存するインターフェースはすべてUseCaseと同じレイヤーに配置されているということです。このあたりに依存性逆転の原則がしっかり守られていると感じました。

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

CMSampleBufferのCの関数使わなくてよくなったみたい

これまでCMSampleBufferからImageBufferを取り出す時などは

let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)

という関数を使っていましたが、iOS13.0から

@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public var imageBuffer: CVImageBuffer? { get }

というプロパティが生えて、

sampleBuffer.imageBuffer

でアクセス出来るようになりました!
imageBufferの他、timingInfoやformatDescriptionも同様に取れるようになっていました。
便利ですね!

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

フライウェイトをSwift5で実装する

※この記事は「全デザインパターンをSwift5で実装する」https://qiita.com/satoru_pripara/items/3aa80dab8e80052796c6 の一部です。

The Flyweight(フライウェイト)

0. フライウェイトの意義

同種のオブジェクトを大量に作らなくてはいけない時、それらの中に共通する不変のデータがある場合、これを複数のオブジェクトから参照するような設計にすると良い。

もし、全オブジェクトが同じデータを直接保持するとなると、生成するオブジェクト数が多い場合はかなりのメモリの無駄につながってしまう。

これがフライウェイトというデザインパターンである。

1. フライウェイトを使わない場合

宇宙船を表すクラスSpaceShipのオブジェクトを1000個作る場合を考える。

SpaceShip.swift
public class SpaceShip {

    //宇宙船の外見(形およびテクスチャ)を表すプロパティ
    private let mesh: [Float]
    private let texture: UIImage?

    //宇宙船の位置を表すプロパティ
    private var position: (Float, Float, Float)

    public init(mesh: [Float], imageNamed name: String, position: (Float, Float, Float)) {
        self.mesh = mesh
        self.texture = UIImage(named: name)
        self.position = position
    }
}
Spaceships.playground
//作るオブジェクトの個数
let fleetSize = 1000

var ships = [SpaceShip]()
var vertices = [Float].init(repeating: 0, count: fleetSize)

for _ in 0..<fleetSize {
    let ship = SpaceShip(mesh: vertices, imageNamed: "SpaceShip", position: (
            Float.random(in: 1...100),
            Float.random(in: 1...100),
            Float.random(in: 1...100)
            )
        )
    ships.append(ship)
}

for-inループの中でメッシュやテクスチャまで1000回割り当てを行なっている。これらは全SpaceShipオブジェクトで共通していて、変わることがない。にも関わらず1000回も生成することはいかにも無駄である。

2. フライウェイトを使う場合

SpaceShipオブジェクトに共通する要素をSharedSpaceShipDataクラスとしてくくり出す。

SpaceShip.swift
public class SharedSpaceShipData {
    private let mesh: [Float]
    private let texture: UIImage?

    public init(mesh: [Float], imageNamed name: String) {
        self.mesh = mesh
        self.texture = UIImage(named: name)
}

mesh, textureプロパティを抜いた形で、SpaceShipクラスを再定義する。

SpaceShip.swift
public class SpaceShip {
    private var intrinsicState: SharedSpaceShipData
    private var position: (Float, Float, Float)

    public init(sharedData: SharedSpaceShipData, position: (Float, Float, Float)) {
        self.intrinsicState = sharedData
        self.position = position
}

実際にSpaceShipクラスのオブジェクトを1000個生成する際は、まずSharedSpaceShipDataクラスのオブジェクトを一つだけ作った後、全SpaceShipオブジェクトがこれを参照するようにする。

Spaceships.playground
//生成するオブジェクトの個数
let fleetSize = 1000

var ships = [SpaceShip]()
var vertices = [Float].init(repeating: 0, count: fleetSize)

//まず共通するデータを表すオブジェクトを一つだけ生成する。
let sharedState = SharedSpaceShipData(mesh: vertices, imageNamed: "SpaceShip")

for _ in 0..<fleetSize {
  //宇宙船オブジェクトが共通データを参照するようにする
    let ship = SpaceShip(sharedData: sharedState, position: (
            Float.random(in: 1...100),
            Float.random(in: 1...100),
            Float.random(in: 1...100)
            )
        )
    ships.append(ship)
}

注意点としては、共有データであるSharedSpaceShipDataは参照型(クラス)として宣言しなくてはならないということである。

値型(構造体)として宣言すると、各SpaceShipオブジェクトに割り当てる際にコピーされてしまうため、結局Flyweightパターンを用いないのと同じ結果となってしまう。

※値型と参照型の違いについてはこちらの記事などを参照
https://qiita.com/chihayafuru/items/f6bb38c4a34d8cf62aa9

https://github.com/Satoru-PriChan/FlyweightSpaceShipDemo

参考文献: https://www.amazon.com/Design-Patterns-Swift-implement-Improve-ebook/dp/B07MDD3FQJ

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

[iOS/Swift] アプリ開発の実務的アプローチで学ぶデザインパターン ~Mediator~

この記事シリーズは、iOS/Swiftエンジニアである執筆者個人が、
ごく普通のiOSアプリ開発でよくある状況
Swiftのコアライブラリやフレームワークで使われているパターン
着目してデザインパターンを学び直してみた記録です。

関連記事一覧
[iOS/Swift] アプリ開発の実務的アプローチで学ぶデザインパターン

Mediatorパターン概要

  • Mediatorとは「仲介者」という意味です。
  • 複数のオブジェクト間で直接やり取りをせずにMediatorを介してやり取りします。
  • 各オブジェクトが依存する相手をMediatorだけにすることで、オブジェクト同士が疎結合になり、関連オブジェクトが多い場合には保守性を向上できます。
  • GoFのデザインパターンでは振る舞いに関するパターンに分類されます。

使い所

実務的な例としては、UIPageViewControllerの配下にある子ViewController同士の通知が考えられます。
以下のサンプルコードは、赤背景のViewControllerが非表示になる時に、青背景のViewControllerに通知を行う例です。

qiita20200119.gif

UIPageViewControllerがMediatorの役割を担っています。
メリットは、新たにReceiverとなる子ViewControllerが増えた時、既存の子ViewControllerは変更しなくても済む点です。

サンプルコード

Xcode 11.3でシングルページアプリケーションを新規作成し、ViewController.swiftに以下のコードをコピペすれば動作します。

// MARK: - プロトコル
protocol Receiver {
    func receive(message: String)
}

protocol Sender {
    func send(message: String)
}

protocol Mediator: class {
    var recipients: [Receiver] { get }
    func send(message: String)
}

// MARK: - PageViewControllerの子ViewController
// 送り手のViewController
final class SenderViewController: UIViewController {
    // Message送信をMediatorに委譲する
    weak var messageDelegate: Mediator?

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // 表示されなくなった時にMessageを送信(Mediatorに委譲)
        messageDelegate?.send(message: "SenderViewController.viewDidDisappear()")
    }
}

// 受け手のViewController ※Receiverプロトコルに準拠
final class RecieverViewController: UIViewController, Receiver {
    func receive(message: String) {
        print("\(message)を受信しました")
    }
}

// MARK: - PageViewController
final class ViewController: UIPageViewController {
    // 送り手のViewController
    let senderViewController = SenderViewController()
    // 受け手のViewController
    let receiverViewController = RecieverViewController()
    // 子ViewController配列
    var controllers = [UIViewController]()

    override func viewDidLoad() {
        super.viewDidLoad()
        // PageViewControllerの子ViewControllerを設定
        senderViewController.view.backgroundColor = .red
        controllers.append(senderViewController)

        receiverViewController.view.backgroundColor = .blue
        controllers.append(receiverViewController)

        setViewControllers([controllers[0]], direction: .forward, animated: false, completion: nil)
        dataSource = self

        // SenderViewControllerの委譲先に自分を設定する
        senderViewController.messageDelegate = self
    }
}

// PageViewControllerをMediatorプロトコルに準拠
extension ViewController: Mediator {
    var recipients: [Receiver] {
        // 子ViewControllerの中でReceiverプロトコルに準拠しているものを返す
        return controllers.filter { $0 is Receiver } as! [Receiver]
    }

    func send(message: String) {
        for recipient in recipients {
            recipient.receive(message: message)
        }
    }
}

※説明には無関係ですが動かす時にコピペが必要なコード
// UIPageViewControllerDataSource
extension ViewController: UIPageViewControllerDataSource {
    // 右にスワイプ(戻る)
    func pageViewController(_ pageViewController: UIPageViewController,
                            viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard
            let index = controllers.firstIndex(of: viewController),
            index > 0
            else {
                return nil
        }
        return controllers[index - 1]
    }

    // 左にスワイプ(進む)
    func pageViewController(_ pageViewController: UIPageViewController,
                            viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard
            let index = controllers.firstIndex(of: viewController),
            index < controllers.count - 1
            else {
                return nil
        }
        return controllers[index + 1]
    }

    func presentationCount(for pageViewController: UIPageViewController) -> Int {
        return controllers.count
    }
}

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

iOS, Androidアプリの価格変更(キャンペーン)の仕方

はじめに

以前アプリをリリース(iOS, Mac, Androidのアプリをリリースしてみた
)したのですが、複数の方から100円も払えるかクソが!!といわれました:sob:

まだ機能が思ってる完成形には達していないし、やっぱだれかがダウンロードしてくれないとおもしろくないししばらくは無料にすることにしました!!

たまにみるキャンペーンみたいなので期間限定無料をやりたいと思い調べていたのですがやり方があんま出てこなかったので備忘録として書きます。

iOS, Mac版

iOSとMacアプリについてです。やり方はめっちゃ簡単。(だからあんま調べてもでてこないのかも)

  1. 下記ページを開きます
    App Store Connect -> マイ App -> 対象アプリ -> App Store -> 価格および配信状況
  2. 価格表の「価格変更を計画」をクリック
  3. 価格、開始日、終了日を選択
    ios_price
  4. 「終了」クリック
  5. 「保存」クリック

終了日は元の価格に戻る日付のようです。(2月いっぱい無料にしたかったのに間違えた:see_no_evil:

ios_sell

Android版

Androidアプリについてです。こっちもめっちゃ簡単。

  1. 下記ページを開きます
    Google Play Console -> 対象アプリ -> ストアでの表示 -> 価格と配布 -> セール
  2. セール名を設定
  3. セール期間を設定(最大8日間らしいです) android_sell
  4. 価格を設定(最低30%オフらしいです) android_price_annotation
  5. 「販売スケジュールを設定」クリック

一旦セールを行うと次のセール作成は30日間おこなえないようです。

android_new_sell

参考:Play Console ヘルプー有料アプリのセールを作成する

さいごに

セールの開始日を当日にすると反映までは多少時間がかかるみたいです。余裕をもってセールを設定しましょう!!(iOSは3時間くらいでAndroidは8時間くらいでした)

今無料なんでだれかダウンロードしてください:bow_tone1::bow_tone2::bow_tone3::bow_tone4::bow_tone5:

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

GatsbyJSとNetlifyでiOS/AndroidアプリのLPを勢いで作ってみた

経緯

2020年1月にiOSアプリをリリースして、もともとAndroidのアプリがあったので
これは今ならLP作れるのでは... :sparkles:
という浅い考えと、何か新しい方法で試してみたいという欲求から今回は
GatsbyJSとNetlifyを使ってLPを作る事にしました。

GatsbyJSとNetlify

:computer:環境構築


今回はこちらのテンプレートを使用してLPを作成したいと思います。

$ gatsby new app-lp https://github.com/anubhavsrivastava/gatsby-starter-newage

早速起動してみます。

$ cd app-lp
$ gatsby develop

ブラウザで http://localhost:8000/にアクセスして↓のページが表示されればOKです。
image1.png
LPポイですねw (当たり前)

ここまで出来たら、一度Github上にリポジトリを作成しPushします。
今回はPrivateなリポジトリでも可能なのか検証も含めPrivateリポジトリで試しました。
(確認後Publicにしてます)

:pencil: サイトページの内容を修正


基本的な情報

config.js で設定されているので、ここを適宜修正します。

module.exports = {
  siteTitle: '[アプリ名]', // <title> ☆修正
  manifestName: 'app-lp', // ☆修正
  manifestShortName: 'Landing', // max 12 characters
  manifestStartUrl: '/',
  manifestBackgroundColor: '#EB6101', // ☆修正
  manifestThemeColor: '#EB6101', // ☆修正
  manifestDisplay: 'standalone',
  manifestIcon: 'src/assets/img/website-icon.png', // ☆アイコン修正
  pathPrefix: `/app-lp/`, // ☆修正
  // social ☆必要に応じて修正
  socialLinks: [
    {
      icon: 'fa-github',
      name: 'Github',
      url: 'https://github.com/xxxx',
    },
    {
      icon: 'fa-linkedin-in',
      name: 'Linkedin',
      url: 'https://linkedin.com/in/xxxx/',
    },
    {
      icon: 'fa-twitter',
      name: 'Twitter',
      url: 'https://twitter.com/xxxx',
    },
    {
      icon: 'fa-facebook-f',
      name: 'Facebook',
      url: 'https://facebook.com/xxxx',
    },
  ],
};

Footerの修正

まずは簡単な所から、src/components/Footer.js のコピーライトの部分を修正しました。

import React from 'react';

export default function Footer() {
  return (
    <footer>
      <div className="container">
        <p>&copy; [名前]. All Rights Reserved.</p>
      </div>
    </footer>
  );
}

コンテンツ全般の修正

次にコンテンツ全般の修正をガッツリ行いました。主に修正するファイルは
src/pages/index.js になります。
また、適宜CSSも微調整しました。

Netlifyでページ公開

ページを修正したら、早速デプロイして公開していきたいと思います。
Netlifyにログインすると以下のページが表示されます。
image2.png

早速Githubのレポジトリと連携しようと思うので、「New from site Git」から連携するリポジトリを選択します。

設定はデフォルトのままで「Deploy site」で早速デプロイしてみます。

これだけでデプロイが完了しました :tada: 簡単。

最後にサイト名を修正します :sparkles:
image7.png

今回作成したLPは以下に公開してます !
https://ss-map-lp.netlify.com/
※ドメイン変更まで行うと有料になるので、今回はやりませんでした。

リポジトリはこちら
https://github.com/Slowhand0309/ss-map-lp
(※Privateでも大丈夫そうだったので、今は公開してます)

ものの数時間でデプロイできました :sparkles: 更新があっても後はリポジトリにPushするだけなのでとても便利そうです。

:link: 参考になったURL


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