【イベントレポート】Extended Tokyo - WWDC 2023を開催しました!

Extended Tokyo - WWDC 2023

はじめに

こんにちは。ZOZO DevRelブロックの@wirohaです。6月5日の深夜から6月6日にかけてExtended Tokyo - WWDC 2023を開催しました。

Extended Tokyoは、WWDCのメインセッション(Keynote)をさらに楽しむためのイベントです。今年もLINE株式会社、株式会社ZOZO、ヤフー株式会社の3社で主催しました。オフライン会場は2019年以来のヤフー紀尾井町オフィスにあるLODGEです。またオンライン会場は2021年ぶりにclusterのVR LODGEとハイブリッドで開催しました!

イベント内容まとめ

WWDCのKeynoteは日本時間で深夜2時からです。それに合わせて本イベントも23時30分と遅い時間からはじまりました。クイズ大会、LT大会で気分を高めた後、リアルタイムでKeynoteを視聴しました。

コンテンツ 登壇者
クイズ大会
LT1:Appleの進化を楽しむための歴史の授業 新妻 広康◆ヤフー
LT2:あなたの知らないWWDC現地参加の世界
〜Apple Parkへ行った僕が見た、新しいWWDC〜
荻野 隼◆ZOZO
LT3:WWDC「間」を復習しよう 平井 亨武◆LINE
LT4:メタバースプラットフォーム開発におけるSwiftUIの活用とTips 董 亜飛◆cluster
交流会
Keynote視聴

クイズ大会

WWDCや各社にちなんだクイズ大会でイベントスタートです! 正解した方にはノベルティが贈られました。

じゃんけんのようにクイズに回答

正解者へのプレゼント

現地中継

現地からは歓声も聞こえてきます

イベント中、何度か現地参加者とビデオ通話をつないで様子を伝えていただきました。とても明るく良い天気で、日本との気候の違いを感じますね。話しているとちょうど開場がはじまり、人がドッと動き出しました! 臨場感が伝わってきます!

Appleの進化を楽しむための歴史の授業

ヤフー株式会社 新妻さま

www.docswell.com

LT大会へと移り、新妻さまからはXcodeが生まれる前に遡って開発の歴史を紹介いただきました。AutoLayout、Swift、SwiftUIはアプリ開発の問題を解決する大きなソリューションですね。

あなたの知らないWWDC現地参加の世界 〜Apple Parkへ行った僕が見た、新しいWWDC〜

株式会社ZOZO 荻野

speakerdeck.com

ZOZOの荻野からは2022年のWWDCに現地参加した体験を時系列で発表しました。会議室やミニコンテンツ、現地で盛り上がった場面やトイレまで知れるのは面白かったです。今年もZOZOから現地に参加しているメンバーがおり、写真とメッセージを共有させていただきました!

WWDC「間」を復習しよう

LINE株式会社 平井さま

speakerdeck.com

平井さまからは1年でWWDCまでの間にあった出来事をご紹介いただきました。間に起きた出来事の中で、App Storeの価格の設定方法のアップデートとUIViewController.ViewLoadingについて詳細を解説いただきました。価格設定は悩ましいと共感の声が出ていました。

メタバースプラットフォーム開発におけるSwiftUIの活用とTips

クラスター株式会社 董さま

speakerdeck.com

董さまからはclusterでのSwiftUIの知見を発表いただきました。マルチプラットフォーム対応で毎週リリースしているのはすごいですね。タブインジケータや画像のズーム、Truncated Textの詳細な実装を解説いただきました。ARデバイスの発表に期待する声は他の発表でも出ていました。

交流会

Apple Park内の様子が気になるみなさま

Keynoteがはじまるまでは交流会を行いました。登壇者も発表が終わってホッとした様子でみなさんとお話を楽しんでいました。現地とも通話をつないで今年は何があるのか聞いたりしました。

Keynote視聴

ついにスタート!!

交流を楽しんでいるとあっという間にKeynoteの開始時刻となりました! 新しい情報には「おぉー」と声が上がったり笑いやどよめきが起きたり、みなさんと気持ちを共有できる楽しさを感じました。15インチMacBook Air、M2 Ultra、iOS 17など新しい情報が盛りだくさんでしたね。何よりApple Vision Proにはオフライン会場が沸きました! すごい、使ってみたいと早速感想を分かち合いました。

最後に

みなさま夜遅い時間にもかかわらずご参加ありがとうございました。WWDCの詳細をもっと知りたいと思った方はぜひ6月27日の「WWDC23 報告会 at LINE, ZOZO, ヤフー」にご参加ください。WWDCに参加した各社のエンジニアが、新しく発表された技術や得た知見、情報などを共有します。

line.connpass.com

ZOZOでは一緒にサービスを作り上げてくれるiOSエンジニアを募集中です。ご興味のある方は以下のリンクからぜひご応募ください。

https://hrmos.co/pages/zozo/jobs/0000012hrmos.co

Swift 6に向けた準備:Strict Concurrency CheckingをTargeted設定にした際に発生した問題と解決方法

Strict-Concurrency-Checkingのタイトル画像

こんにちは、フロントエンド部の中島です。FAANSのiOSアプリの開発を行なっています。 FAANSの由来は「Fashion Advisors are Neighbors」です。「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」で2022年8月に正式ローンチしました。

はじめに

FAANS iOSチームではAPI通信においてSwift Concurrencyを利用しています。Swiftに限らず並行処理を扱う場合には実装次第でデータ競合を起こす恐れがあるのに対して、Swiftではデータ競合を防ぐ仕組みとしてActorが導入されています。そして、Actor間で扱うデータがデータ競合を起こさない型であるかコンパイラでチェックされます。Swift 6ではこのデータ競合のチェックにより既存のコードでコンパイルできなくなる可能性があります。Xcode 14ではSwift 6までの間に段階的な移行ができるようにStrict Concurrency Checkingでコンパイラのチェックレベルを指定できるようになりました。本記事ではFAANS iOSチームで実施したStrict Concurrency Checkingの対応と、その過程で得られた知見について紹介します。

続きを読む

ZOZOFIT iOSアプリ開発の全貌

ogp

はじめに

こんにちは、計測プラットフォーム開発本部アプリ部の中岡、永井、東原です。私たちのチームではZOZOMAT、ZOZOGLASSといった既存の計測機能の改善と、新規計測アプリの研究開発を担当しています。

その新規計測アプリとして、ZOZOFITというボディーマネジメントサービスを2022年の夏に米国でローンチしました。この記事では、ZOZOFITのiOSアプリを新規開発するにあたって、どのような技術要素を取り入れたかについてご紹介します。

目次

ZOZOFITとは

ZOZOFITは、ZOZOグループのZOZO Apparel USA, Inc.が提供するボディーマネジメントサービスであり米国でサービス提供をしています。

初代ZOZOSUITよりも大幅に計測精度を向上させたZOZOSUITを着用し、専用のスマートフォンアプリを利用することで手軽に3Dボディースキャンを行い、計測データをトラッキングできます。

計測可能な箇所は、肩幅、胸囲、腕周囲、ウエスト周囲、ヒップ周囲、太もも周囲、ふくらはぎ周囲の7箇所であり、体脂肪率も測定されます。計測データは下の画像のようにアプリ上で確認でき、過去のデータとの比較やグラフ表示が可能です。また、目標の管理機能によりそれぞれの計測部位について目標を設定でき、達成状況を確認できます。

ZOZOFIT iOS アプリ

図1:ZOZOFIT iOSアプリ

開発についてはグループ会社であるZOZO New Zealandと協業して進めています。プロジェクト全体の話は下記の記事がありますので、ぜひご覧ください。

technote.zozo.com

計測機能とその実装・統合

計測プラットフォーム開発本部アプリ部の永井です。ここでは、ZOZOFITの主要機能である計測機能の詳細と、その計測機能がiOSアプリへどのように実装・統合されているかについて説明します。

計測機能について

まずは、計測機能がどのようなものかをユーザー視点で説明していきます。

計測は、スーツを着用したユーザーの全身をスマートフォンの背面カメラで360度撮影することによって行われます。以下の図は、アプリ内のチュートリアルで使われているものです。

計測方法

図2:計測方法

このように計測時、ユーザーはスマートフォンをスタンドに立てかけ、そこから2mほど離れた場所に立つ必要があります。そして、その場で体を0時から12時まで回転させながら、合計12枚の写真を撮影します。

計測の最中、ユーザーには背面カメラが向けられていて画面を見ることができないため、計測を進めるための案内はすべて音声により行われます。

そうして案内に沿って計測を完了させると、アプリ上で全身の3Dモデルと計測データを見ることできます。以下の図は実際のアプリ画面です。

計測結果

図3:計測結果

このように自身の身体の気になる部位について、いろいろな角度から3Dモデルを見たり、計測データの変化をグラフで追ったりできます。

計測機能の実装・統合について

計測機能の実装・統合の説明にあたって、先に計測機能のアルゴリズムを紹介します。詳細は伏せますが、簡略化すると以下のようになります。

  1. ZOZOSUITを着用したユーザーを360度、12枚の写真として撮影
    • ユーザーとスマートフォンとの距離やユーザーの身体の回転具合などに問題がないかをチェックする
  2. 撮影した写真を画像処理
    • スーツ全体に施されたドットマーカーのパターン認識
    • ユーザーの身体のシルエット検出
  3. 画像処理の結果から3Dモデルを生成
    • 生成された3Dモデルから各部位の計測データが得られる

このアルゴリズムはZOZO New Zealandが開発したC++ライブラリによって提供されており、その中で、OpenCVやMediaPipeのような画像処理・機械学習のライブラリが使われています。MediaPipeはソースコードが公開されており、利用したい機能をZOZOFIT向けにカスタマイズできることから採用に至りました。

また、計測結果の3Dモデル描画は、WebGL(Three.js)で実装されたものをWeb Viewで表示する仕組みとなっており、iOSアプリではWKWebViewが使われています。

計測機能がこのような実装となっている大きな理由は、クロスプラットフォームのためです。ZOZOFITはAndroid・iOSの2つのプラットフォームでアプリを展開しており、主要機能である計測機能については両プラットフォームで共通のものを提供することが重要でした。そのためネイティブとは切り離された、両プラットフォームに対応する技術を用いて実装されています。

ZOZOFITに限らず、これまでのZOZOMATやZOZOGLASSといった計測プロダクトでもクロスプラットフォームは大きな関心事でした。これまでの計測プロダクトについては下記の記事がありますので、興味があればぜひご覧ください。

techblog.zozo.com techblog.zozo.com

さて、以下の図は、計測機能がどのように統合されているかを示したものです。

計測機能の統合

図4:計測機能の統合

iOSアプリのリポジトリ内で、サブモジュールとして計測ライブラリと3D Model Viewerのリポジトリを参照しています。計測ライブラリについては、CMakeコマンドによりXcodeプロジェクトを生成することで、ワークスペース内で利用できるようにしています。

iOSアプリの技術要素

計測プラットフォーム開発本部アプリ部の中岡です。ここではZOZOFIT iOSの技術要素について説明します。

使用技術

2023年3月時点では以下のような技術構成となっています。

  • 開発言語:Swift 5.7
  • 対応OS:iOS 15~
  • UIフレームワーク:SwiftUI(一部UIKit)
  • CI/CD:Bitrise
  • パッケージ管理:Swift Package Manager
  • ライブラリ:FactoryChartsSwiftGenSwiftLintswift-snapshot-testing など。
  • その他ツール:Figma、TestFlight、Firebase

対応OS

基本的に最新バージョンから1つ前のメジャーバージョンまでをサポートする方針となっています。開発当初はiOS 16がリリースされていなかったのですが、ZOZOFITがリリースされる2022年8月にはiOS 16がリリースされているということもあり開発当初からiOS 15~で開発していました。

また、開発体験に関してはiOS 14をサポートするより向上はしましたが、本アプリ開発においてそこまで大きく変わったという印象はありませんでした。

UIフレームワーク

基本的にSwiftUIをベースに開発していますが、一部実装が困難な部分はUIKitを使用しています。具体的には以下の図のように最前面にローディング画面を表示することがSwiftUIだけでは困難でした。そのためUIKitのUIWindowを使用して最前面に表示しています。

hierarchy

図5:ローディング表示時のView階層

CI/CD

CI/CDにはBitriseを使用しており、PRが作られた際に自動でテストを実行するワークフローとApp Store Connectにアップロードするワークフローがあります。また、これらの設定のbitrise.ymlは同リポジトリで管理しています。

パッケージ管理

パッケージの管理は全てSwift Package Managerで行なっています。また、SwiftLintやSwiftGenといったツールもプラグイン機能を活用しバージョン管理をしています。

その他ツール

デザイン、テスト用アプリの配布はそれぞれFigma、TestFlightを使用しています。また、FirebaseはCrashlytics、Analytics、Dynamic Linksを使用するために導入しています。Analyticsの導入については下記の記事を公開しているので興味がある方はこちらをご覧ください。

techblog.zozo.com

アーキテクチャ

基本的に以下のようなView、Config、Managerという構成をとっています。

  • View
    • いわゆる見た目の部分です。SwiftUI.Viewで書かれており、ユーザからのイベントをConfigに渡します。
  • Config
    • MVVMでいうViewModelのような役割です。ObservableObjectプロトコルに準拠したオブジェクトでViewの状態管理や受け取った値をManagerに渡します。
  • Manager
    • MVVMでいうModelのような役割です。アプリのビジネスロジック部分で計測アルゴリズムの実行や、計測データの管理などを行なっています。

プロジェクト構成

ZOZOFIT iOSのプロジェクト構成は以下の図のようになっています。コアロジックや共通コンポーネント、カスタムModifier等をパッケージ化しそれらをXcodeプロジェクトから呼び出しています。

プロジェクト構成

図6:ZOZOFIT iOSの依存関係

今後の課題

ローンチからまだ1年足らずということもあり、現状のZOZOFIT iOSにはいくつかの課題が残っています。

まずは、テストが行いづらいという点が挙げられます。現在一部のManagerが@Publishedプロパティを持っておりObservableObjectとしてViewから参照されています。Mock化するためにこれらのProtocolを定義したいのですが、SwiftのProtocolでは@Publishedプロパティを定義できずコンパイルエラーとなってしまいます。そのためこれらのManagerに依存しているViewやConfigのテストが行いづらくなってしまっています。加えて、ZOZOFITはプロダクトの特性上、カメラやセンサデータを使用するため計測機能をデバッグするには実機で動かす必要があり時間と手間がかかるといった課題もあります。これについては、カメラを使わず事前に用意した画像を読み込むようにすることで改善できます。ZOZO New Zealandの開発チームがSDKを作成する際にそのような機能を持つアプリを用意していたので、その機能をZOZOFITアプリにも取り込めると良いなと思っています。

また、計測機能の統合方法についても改善の余地があります。現在はCMakeによって、C++で書かれた計測ライブラリのXcodeプロジェクトを生成し、ワークスペース内で統合するというアプローチをとっています。しかし、理想的には計測ライブラリをXCFramework化して、統合することがよりシンプルで望ましいと考えています。

効率的に開発をするためにも今後チームで話し合いこれらの課題は解決していきたいです。

おわりに

ZOZOFITのiOSアプリについてその全体像をご紹介しました。アプリは昨年リリースされたばかりであり、より多くのユーザーに使っていただくために改善や新機能の追加を行なっています。例えば現在は機能開発に加えて、データをより活用できるようにGoogle Analyticsの測定箇所を見直したり分析レポートの作成に取り組んでいます。ZOZOFITはZOZO New Zealandとの協業のプロジェクトであり関係者が多く言語の壁もあるので簡単なプロジェクトではありませんが、プロジェクト全体の改善も行いながら進めているところです。

これからのグロースを目指し、海外チームと協業しながらSwiftUIを利用してiOSアプリ開発をしていく、ということに少しでも興味のある方は以下のリンクからぜひご応募ください。

corp.zozo.com

CompositionalLayoutを用いた横スクロールのレイアウト改修で直面した問題と解決方法

こんにちは。WEAR部iOSチームの小野寺です。

先日CollectionViewで実装しているトップページを改修しました。

改修はトップページに並べていたコンテンツを1つにまとめて、横スクロール(手動 / 自動)によってコンテンツを切り替え可能にしました。

CompositionalLayoutで横スクロール

横スクロールによってコンテンツを切り替える仕様なので、CompositionalLayoutで実装しました。

上記の方針で進めていく中で、困難な実装に直面したので紹介します。

セクション全体への装飾

最初に直面した問題が、セクション全体に対するViewの装飾です。今回画像の赤枠部分について、次のように改修が必要になりました。

トップページ改修の要件

  1. 横スクロールで、コンテンツを切り替えられるレイアウトに変更
  2. その上に固定で表示され続けるViewを被せる(画像の赤枠)
  3. 固定で表示するViewの高さはタグ名の長さによって変わる
改修前 改修後
トップページ改修前 DecorationViewの固定表示

レイアウト調整にコストがかかる

固定で表示され続けるViewは、UICollectionReusableView, NSCollectionLayoutBoundarySupplementaryItemを使用しました。

func createLayout() -> UICollectionViewCompositionalLayout {
~~ 省略:横スクロール用のレイアウト設定 ~~

let decorationViewHeight = 150

let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
                                                                elementKind: “tag-container-element-kind”,
                                                                alignment: .bottom,
                                                                absoluteOffset: .init(x: 0, y: -decorationViewHeight))
section.boundarySupplementaryItems = [decorationView]

~~ 省略 ~~
}

追加したViewのy座標を、セクションの乗せたい位置(decorationViewHeight)までずらすことで、セクション全体へかかるようにしています。

ここではまだ、高さが変わることを考慮していないので、内容によってはレイアウトが崩れてしまいます。

CompositionalLayoutのフッターViewのレイアウトの差分

Viewの高さを計算する対応を追加し、コンテンツの表示に必要な高さを確保します。

class DecorationView: UICollectionReusableView {
~~ 省略 ~~
    
    static func calcViewHeight(title: String, containerSizeOfWidth: CGFloat) -> CGFloat {
        let decorationView = calculationBaseView
        decorationView.label.text = title
        decorationView.setNeedsDisplay()
        decorationView.layoutIfNeeded()
        let layoutViewSize = layoutView.systemLayoutSizeFitting(CGSize(width: containerSizeOfWidth, height: 0),
                                                                withHorizontalFittingPriority: .required,
                                                                verticalFittingPriority: .fittingSizeLevel)
        return layoutViewSize.height
    }
    
~~ 省略 ~~    
}

追加したcalcViewHeight()をdecorationViewHeightへ反映させます。

func createLayout() -> UICollectionViewCompositionalLayout {
~~ 省略:横スクロール用のレイアウト設定 ~~

// let decorationViewHeight = 150
let title = "冬がはじまるよ"
let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))!
let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width)

let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
                                                                elementKind: “tag-container-element-kind”,
                                                                alignment: .bottom,
                                                                absoluteOffset: .init(x: 0, y: -decorationViewHeight))
section.boundarySupplementaryItems = [decorationView]

~~ 省略 ~~
}

コンテンツの内容によって高さを取得する必要があるのでレイアウト調整にコストがかかっています。

OSによってはフッターが非表示になる

さらに、iOS14.5未満のOSバージョンで、フッターがセルの裏に隠れてしまう問題もありました。

iOS14.5未満でのフッターViewのレイヤー

この事象に対しては、UICollectionReusableViewを使った対応が取れませんでした。

今回は後にターゲットOSを上げる予定があった為、暫定対応としてUICollectionReusableViewに直接実装したコンテンツをカスタムUIViewとして切り出しました。

事象が発生するバージョンではUICollectionReusableViewは使用せず、このカスタムUIViewをCollectionViewのSubViewとして扱う改修で対応しました。

カスタムUIViewとして扱う

コンテンツの自動スクロール

レイアウトの改修の次は、自動でコンテンツをスクロールさせる機能の追加です。

自動スクロール機能の要件

  1. 一定時間に画面の操作がない場合に、次のセルを表示
  2. 最後のセルまで表示させたら、先頭に戻る

carouselの自動スクロール成功例

自動スクロール中に意図しないスクロールの発生

自動スクロールの対応は、はじめに以下の方針で検討しました。

  1. 一定時間でスクロールできるように、Timerを追加して定周期でスクロール処理を呼び出す。
  2. スクロール処理は、scrollToItem(at:at:animated:)をデフォルトのアニメーションを有効にして、コンテンツをスクロールさせる。

Timer側からの呼び出し

Timer.scheduledTimer(
    withTimeInterval: 3.0,
    repeats: true,
    block: { [weak self] _ in
        guard let self = self else { return }
        let toItem = displayContentIndexPathItem + 1
        
        self.scrollToItemForCarousel(at: .init(item: toItem, section: 0))
    }
)

定周期で呼び出すスクロール処理

func scrollToItemForCarousel(at indexPath: IndexPath) {
    collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}

実際動作させてみたところcollectionView.contentOffset.yが、アニメーションの度に改修したセクションのデフォルトの位置へと、引き戻される問題が発生しました。

carouselの自動スクロール失敗例

原因は、scrollToItem(at:at:animated:) へ第1引数で渡しているindexPathにありました。スクロール先の指定にindexPathが使用されることで、x座標、y座標それぞれに対してscrollToItemが作用してしまいます。

これによりCollectionViewの垂直方向が、少しでもスクロールされた状況下の場合に、事象が発生してしまいました。

自作の自動スクロールのアニメーションを追加

問題を解消するために、今回はscrollToItem(at:at:animated:)のデフォルトのアニメーションは使わず、次のような自作アニメーションを実装しました。

  1. 自動スクロール前のcontentOffsetの位置を保持する
  2. scrollToItem(at:at:animated:)でスクロール位置を更新
  3. y座標を調整前の値で更新する
  4. 2と3を1つのアニメーションとして扱う
func scrollToItemForCarousel(at indexPath: IndexPath) {
    let currentOffset = collectionView.contentOffset
    
    UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { [weak self] in
        guard let collectionView = self?.collectionView else { return }
        
        collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
        collectionView.contentOffset.y = currentOffset.y
    }
}

scrollToItemでのindexPathの更新とy座標をもとに戻す対応を1つのアニメーションとして実装することで、引き戻される問題を回避しています。

先頭へ戻るアニメーションにコストがかかる

ここまでの対応で、自動で次のコンテンツへ切り替える対応ができました。次に最後のセルまで表示させたら、先頭に戻るアニメーションが必要です。

戻る処理についてもscrollToItem(at:at:animated:)のアニメーションをそのまま使用できれば、簡潔に対応可能です。

(第1引数へ先頭のIndexPathを指定することで、コンテンツが最終位置にいる状態から、アニメーション付きで一気に先頭へ戻す挙動を実現できます)

しかし自作アニメーションを使用する場合は、次のような変更が必要になりました。

  1. UIView.animateのcompletion内で再度自作アニメーションを呼ぶ処理を追加
  2. 引数needsRepeateを追加し、先頭のセルに戻ってくるまでこの値を有効にする
  3. needsRepeateの値が有効の間、自作アニメーションを繰り返す

アニメーション処理を繰り返すことで、最後尾から先頭までのコンテンツの移動を、1つのアニメーションのように見せます。

この際durationの値を調整して、scrollToItem(at:at:animated:)のアニメーションを使った場合の挙動に近づけています。

func scrollToItemForCarousel(at indexPath: IndexPath, needsRepeate: Bool) {
    let currentOffset = collectionView.contentOffse
    let duration: TimeInterval = needsRepeate ? 0.05 : 0.3

    UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut) { [weak self] in
        guard let collectionView = self?.collectionView else { return }

        collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
        collectionView.contentOffset.y = currentOffset.y
    } completion: { [weak self] _ in
        let previousIndex = indexPath.item - 1
        
        if needsRepeate {
            self?.scrollToItemForCarousel(at: .init(item: previousIndex, section: indexPath.section),
                                          needsRepeate: previousIndex >= 0)
        }
    }
}

CompostionalLayoutを使った実装は、レイアウトを組むまでは容易でしたが「セクション全体への装飾」と「コンテンツの自動スクロール」の実装が複雑になり大変でした。

CompositionalLayoutを使わない解決策

これらの実装をCompostionalLayoutを使わず、CollectionViewを中に入れたセル(ContentCollectionViewInCell)を使う方法で考えてみます。

CellにCollectionViewを載せる1

ContentCollectionViewInCellは、横スクロールで切り替え可能なコンテンツの表示に使用するCollectionViewと装飾に使用するViewを配置したもので考えてみます。

ContentCollectionViewInCell.xib CellにCollectionViewを載せる2

ContentCollectionViewInCell.swift

class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource {
    @IBOutlet var collectionView: UICollectionView!
    @IBOutlet var decorationView: DecorationView!
    var contentImages: [UIImage] = []
    
    override func awakeFromNib() {
        super.awakeFromNib()
        configureCollectionView()
    }
    
    func configure(contentImages: [UIImage], labelText: String) {
        self.contentImages = contentImages
        
        collectionView.reloadData()
    }
    
    private func configureCollectionView() {
        collectionView.register(ContentImageCell.self, forCellWithReuseIdentifier: ContentImageCell.identifier)
        collectionView.dataSource = self
        collectionView.collectionViewLayout = createPagingContentLayout()
    }
    
    private func createPagingContentLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { _, _ -> NSCollectionLayoutSection? in
        
            ~~ 省略:横スクロール用のレイアウト設定 ~~
            
            return section
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return contentImages.count
    }
    
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ContentImageCell.identifier, for: indexPath)
        
        ~~ 省略:cellの中身の更新 ~~
        
        return cell
    }
}

セクション全体を装飾するViewの調整

ContentCollectionViewInCellを使用した場合、CompostionalLayoutで苦労したセクション全体を装飾するViewの高さの調整は、オートレイアウトで解決できます。

実際に「ComposionalLayoutを使用した場合」と「ContentCollectionViewInCellを使用した場合」で実装を比較してみます。

ComposionalLayoutを使用した場合
class ViewController: UIViewController {
    ~~ 省略 ~~
    
    func createLayout() -> UICollectionViewCompositionalLayout {
        ~~ 省略:横スクロール用のレイアウト設定 ~~

        let title = "冬がはじまるよ\nアウターはMAST\n見れたらいいねスターダスト"
        let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))!
        // 最前面に配置したViewの高さを計算
        let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width)

        // 最前面に配置したViewの高さの反映と位置を調整する
        let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
        let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
                                                                        elementKind: “tag-container-element-kind”,
                                                                        alignment: .bottom,
                                                                        absoluteOffset: .init(x: 0, y: -decorationViewHeight))
        section.boundarySupplementaryItems = [decorationView]

        return section
    }
    
    ~~ 省略 ~~
}
ContentCollectionViewInCellを使用した場合
class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource, UICollectionViewDelegate {
    ~~ 省略 ~~
    
    func updateDecorationViewLabel() {
        decorationView.contentLabel.text = "冬がはじまるよ\nアウターはMAST\n見れたらいいねスターダスト"
        collectionView.reloadData()
    }
}

ContentCollectionViewInCellを使用した場合、decorationViewが持つラベルの設定とcollectionViewの更新のみで、期待した高さが反映されます。

OS差分の問題

ComposionalLayoutを使用せずにContentCollectionViewInCellを使用した場合は、iOS14.5未満とそうでないOSの差分による影響も無くなります。

コンテンツの自動スクロール機能

コンテンツの自動スクロール機能もContentCollectionViewInCellを使用した場合は、実装が容易になります。

y座標は常に固定で扱うことが可能なことから、scrollToItem(at:at:animated:)の呼び出しのみで対応することが可能になります。

実際に「ComposionalLayoutを使用した場合」と「ContentCollectionViewInCellを使用した場合」で比較すると次のようになります。

ComposionalLayoutを使用した場合
class ViewController: UIViewController {
    ~~ 省略 ~~

    func scrollToItemForCarousel(at indexPath: IndexPath, needsRepeate: Bool) {
        let currentOffset = collectionView.contentOffse
        let duration: TimeInterval = needsRepeate ? 0.05 : 0.3

        UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut) { [weak self] in
            guard let collectionView = self?.collectionView else { return }

            // 次のセルに進めるアニメーション
            collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
            collectionView.contentOffset.y = currentOffset.y
        } completion: { [weak self] _ in
            let previousIndex = indexPath.item - 1
        
            if needsRepeate {
                // 先頭のセルに戻すアニメーション
                self?.scrollToItemForCarousel(at: .init(item: previousIndex, section: indexPath.section),
                                              needsRepeate: previousIndex >= 0)
            }
        }
    }
    
    ~~ 省略 ~~
}
ContentCollectionViewInCellを使用した場合
class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource {
    ~~ 省略 ~~

    func autoScroll(toIndex: Int) {
        let totalContents = 5

        if indexPath.item < totalContents {
            // 次のセルに進めるアニメーション
            collectionView.scrollToItem(at: .init(item: toIndex, section: 0), at: .centeredHorizontally, animated: true)
        } else {
            // 先頭のセルに戻すアニメーション
            collectionView.scrollToItem(at: .init(item: 0, section: 0), at: .centeredHorizontally, animated: true)
        }
    }
    
    ~~ 省略 ~~
}

このように今回紹介したケース単体で見ると、CollectionViewを中に入れたセルを使う対応の方がより簡潔に済みます。

CollectionViewInCellの問題点

CollectionViewInCellにも仕様によっては、かえってコード量の増加や実装の複雑度が上がってしまう問題が考えられます。

考えられる問題

  1. セル毎にCollectionViewの設定が必要になる
  2. 親のセルが表示するセルの状態の管理をするようになる
  3. セルの階層に比例してタップアクションのハンドリングが複雑になる

実現したいUIに対する実装が容易になる一方で、アクションに対する実装やその後の運用が困難になるといったトレードオフがあることを理解しておく必要があります。

おわりに

今回は複数のレイアウトが混在する画面の改修だったため、CompositionalLayoutを使う方針を選択しました。

これにより、横スクロールのような複雑なレイアウトに対しても簡潔に取り入れることができました。

その一方で、凝ったUIの仕様では実装の複雑度が上がってしまいました。

今回は最初に検討していた、CompositionalLayoutで実現する方法を選択しましたが、メリット・デメリットを把握して現状取れる最適な選択をすることが大事だと感じました。

WEARでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は次のリンクからぜひご応募ください。

hrmos.co

ハイブリッド開催に完全対応! iOSDC Japan 2022参加レポート

OGP

はじめに

こんにちは、ZOZOTOWN開発本部の松井とZOZO NEXTの木下です。9/10から9/12までの3日間、iOSDC Japan 2022が開催されました。ZOZOグループからは6名が登壇、20名以上が参加しました。またプラチナスポンサーとして協賛しました。

technote.zozo.com

今年のiOSDCは時代に即した形で、現地会場とオンライン配信によるハイブリッド開催でした。今回は、その両方を盛り上げるために行ったZOZOの取り組みをご紹介いたします。

現地ブース

準備

5月上旬にスポンサー募集が開始され、社内でブース出展の是非を話し合いました。3年ぶりの現地会場であり、多くの来場者と活発にコミュニケーションをとれることを期待して、ブース出展を決定しました。スポンサーのノベルティや掲載物については、iOSエンジニアと広報・CTOブロックが連携して進めました。

展示内容

ブースについてはどういった展示内容にするかも含めて、エンジニアが主体的に進めていくこととなりました。最初にMiroでマインドマップを作成し、ブレインストーミングを行いました。久々のブース出展ということで、前回出展した2019年の様子を振り返りながらアイデアを深めました。

アイデア出しのマインドマップ

その後展示内容ごとに担当者を決め、2週間程度で準備をしました。実際に展示したのは以下です。

  • アンケートやプロダクト紹介が載ったMiroのボード(次の章で詳しく説明します)
  • ZOZO独自の計測テクノロジー「ZOZOSUIT」や「ZOZOMAT」・「ZOZOGLASS」、ZOZOSUITの技術を活用した新事業「ZOZOFIT」
  • 弊社のカルチャーや開発環境の紹介

当日の様子

そして当日できあがったブースがこちらです! ブースに来てくださった方へのノベルティや、ZOZOSUITの技術を活用した新事業、ボディマネジメントサービス「ZOZOFIT」のパッケージと専用アプリ画面の展示です。

現地ブースの様子

トルソーに着せたZOZOSUITの存在感があり、会場から多くの視線を集めていました。

ZOZOSUITの展示

足の3D計測マット「ZOZOMAT」やフェイスカラー計測ツール「ZOZOGLASS」など、現在ZOZOTOWN内でご利用いただける計測テクノロジーもご紹介しました。

様々な計測テクノロジーの展示

ブースではまずアンケートに答えていただき、そこからリモートワークに関する話に花が咲きました。また、ZOZOSUITについて興味を持ってくださった方には、旧ZOZOSUITからの改良点や今後の展望についてお話ししました。

会場全体はこのような雰囲気で、個性を生かしたブースが並んでいました。全てのブースを拝見しましたが、技術的なお話をうかがえたりデモアプリを実際に触れられたりなど、どのブースもとても魅力的でした。

会場全体の雰囲気

ブース出展を通して、対面だからこそできる気軽なコミュニケーションのありがたみを改めて感じました。様々な方と、技術や会社についてお話ししたり、「Twitterでよく見ております!」という会話が生まれたりしました。ブースへ遊びに来てくださった方々、ありがとうございました。

オンラインブース

今年のiOSDCはオフラインとオンラインのハイブリッド開催でしたね。現地に来られない方もZOZOのブースを楽しめるようにと、今年はZOZO独自にオンラインブースも用意していました! iOSDCチャレンジで使われるトークンは、実はオンラインブースにも隠されていたんです。チャレンジした方、見つけられましたか?

準備

ZOZOでは、テックカンファレンスでのオンラインブースの用意は初めてでしたが、Miroを使って全体のデザインからコンテンツの細部までエンジニアがメインで作り上げました。

オンラインブース「ZOZOの広場」

このオンラインブースはコースを進むようにして順番にコンテンツを見ていけるようなデザインにしています。また、リンクを開いた時にまずはなにをしたら良いのか、箱猫マックス(ZOZOの公式キャラクター)が教えてくれるようファーストビューを設定しました。このようにして、参加してくださったお客様が迷わない工夫をしています。

こだわったファーストビュー

そして、オンラインブースでもできる限り現地ブースと同じような体験を作りたい! という思いがありました。そこで、オンライン上でもZOZOのiOSエンジニアに気軽に話を聞きにいけるスペースを用意しました。

当日の様子

オンラインブースのURLはTwitterやZOZO DEVELOPERS BLOG、現地ブースでの2次元コードで発信していました。オンラインブースには多くの人に来場いただき、アンケートは大盛り上がり! たくさんのご回答、ありがとうございました!

アンケートの様子

また、Google Meetにもトークの合間を縫って話に来ていただきました。

ZOZOには「全国在宅勤務制度」があり、日本国内であればどこでも就業可能なため、現地への参加のしやすさは人それぞれです。オンライン参加でもトークを聞いたりiOSDCチャレンジに挑戦したりするだけでなく、Google Meetにて待機することで、イベントの臨場感を味わえました。また、オンライン参加のお客様に対しても現地ブースを擬似体験しているような機会を提供でき、双方にとってよい取り組みとなったように感じます。来年も引き続きオンラインでの開催が行われるようなので、さらに盛り上げていきたいですね!

登壇内容の紹介

今年ZOZOからは6名が登壇しました。昨年に引き続き、会社としての一体感を出すため、登壇者一覧のスライドを社内のデザイナーの方にお願いして作成し、発表で利用しました。

登壇者一覧のスライド

それぞれの登壇内容についてご紹介します。

「PWAの今とこれから、iOSでの対応状況」

木下のレギュラートークです。 登壇時の様子画像提供:iOSDC Japan 2022実行委員会。

ウェブアプリに、ネイティブアプリに近い機能を付加した、Progressive Web Apps (PWA)について発表しました。PWAの概要や歴史から始まり、iOSにおける対応状況やPWAを特徴づける機能、さらにPWAを採用する判断の助けとなるフローチャートなどをまとめました。

ハイブリッド開催ということで直接もしくはTwitter上などで、「PWAって結構色々できそう」という声や、「WebViewだけのアプリを作るならPWA良さそう」など色々と反応いただきました。とても嬉しかったので、また登壇できるように頑張ります。

fortee.jp

「20分間で振り返るIn-App Purchaseの歴史」

@inokinnのレギュラートークです。 登壇時の様子

13年以上の長きに渡る、In-App Purchaseの機能の歴史について発表させていただきました。現地会場やTwitterでは、感想や質問をいくつかいただくことができて非常に嬉しかったです。来年も現地参加するつもりなので、またお会いしましょう!

fortee.jp

「あなたの知らないARの可能性を空間レベルで拡げるVPSの世界」

HEAVEN chan / ikkouのレギュラートークです。 登壇時の様子

Hi, I’m HEAVEN chan! 去年、一昨年はWebARについて喋りましたが、今年はVPSについて喋りました! 今はまだメインストリームの技術ではないものの、今回の発表をきっかけにして少しでも興味を持つ方が増えたら嬉しいです! ついにiOS 16もリリースされたので張り切ってやっていきましょう!

fortee.jp

「Swift Concurrency時代のリアクティブプログラミングの基礎理解」

ばんじゅんのレギュラートークです。 登壇時の様子画像提供:iOSDC Japan 2022実行委員会。

基礎シリーズということで、すぐに使えるとか役立つというものではなかったのですが、みなさん聴いてくれてありがとうございました。Swift Concurrencyとリアクティブプログラミングの例に限らず、たまには基礎に戻って理解を固めておくモチベーションに繋がれば良いなと思っています。並行計算というのはそもそも難しいものなので、今後も着実にやっていきましょう。

fortee.jp

「全力疾走中でも使えるストップウォッチアプリを作る」

Ogijunのレギュラートークです。 登壇時の様子画像提供:iOSDC Japan 2022実行委員会。

みなさんが日常で使っているストップウォッチアプリの使いやすさを陸上競技の選手という特殊な目線で評価し、どんなインタラクションが最適であるかを検証しました。実際に自分が走りながら使った際の動画を見せることで、現状の不便さや作成したアプリの強みを皆様に伝えられたかと思います。他のセッションに比べて異質なテーマでしたが、実際にアプリをDLしてくださったり、フィードバックをいただけてとても嬉しく思います。また来年も登壇できるようこれからも頑張ります!

fortee.jp

「目からビームでヴィランをやっつける 〜ARKitの知られざる並走機能〜」

ながいんのレギュラートークです。 登壇時の様子画像提供:iOSDC Japan 2022実行委員会。

ARKitの一機能である、ワールドトラッキングとフェイストラッキングの並走機能にフォーカスしたトークで発表しました! 初登壇だったのですが、現地・オンラインから「おもしろい!」というリアクションをいただいて、今後のモチベーションに繋がりました。来年はトークだけでなく原稿にも挑戦したいです!

fortee.jp

CfPネタ出し会 & レビュー会

弊社では毎年、自由参加でiOSDCのCfPネタ出し会 & レビュー会をiOSエンジニア同士で行っています。今年も例年通り開催しました。詳細については昨年のブログで紹介しています。

techblog.zozo.com

今年は昨年と比べてプロポーザルの募集期間が短かったため、次のようなスケジュールで進めました。

5/12にCfPの募集が開始され、5/25に2時間程度のネタ出し&レビュー会を開催しました。会ではドキュメントに予めネタを書き出しておき、相互にレビューしました。またレビューを進める中で新たにネタを思いついた場合には書き足していくというのを行い、最終的に約25件のネタが集まりました。

6/1に技術顧問である岸川さんより、CfPを読む人にトークの期待値が伝わりやすくなっているかなど様々な観点から最終レビューをいただきました。

結果的に6件のトークが採択されました。ネタ出し&レビュー会はここ数年で定着すると共に、書き方に関するノウハウもたまってきています。来年以降も引き続き実施していこうと考えております。

また弊社ではカンファレンスへの参加は業務として扱われるため、iOSDCには休日出勤という形で参加しました。今年も内定者アルバイトの方が複数名イベントへ参加しましたが、社員と同様にチケット代は経費となり、イベントへの参加は業務時間として扱われています。

ZOZOでは、来年のiOSDC Japanへ一緒に参加してくださるエンジニアを募集しています。ご興味のある方はこちらからご応募ください。

hrmos.co

https://hrmos.co/pages/zozo/jobs/0000210hrmos.co

ZOZOTOWNホーム画面におけるログ設計と改善サイクルの紹介

データドリブンなトップ面

はじめに

こんにちは、ML・データ部推薦基盤ブロックの宮本(@tm73rst)です。普段は主にZOZOTOWNのホーム画面や商品ページにおいて、データ活用やレコメンド改善のプロダクトマネジメントを行っております。

近年ビックデータ社会と言われる中、データドリブンという言葉をよく耳にします。ZOZOTOWNのホーム画面は、ホーム画面の各パーツごとにViewable Impression(以降、view-impと表記)を取得できるようになったことでデータドリブンな評価や意思決定が促進されました。

本記事では特にZOZO独自のview-impの設計とview-impを用いてどのようにホーム画面を改善しているかについて紹介します。データドリブンな施策の推進を検討している方に向けて、本記事が参考になれば幸いです。

本記事におけるViewable Impressionの定義

本記事ではホーム画面のview-impの定義を「ホーム画面の各パーツに対して、ユーザーが実際に目に触れて認識した閲覧ログ」とします。そのため、パーツの内容を把握できない瞬間的な表示や部分的な表示はview-impの発火と見なさず、表示時間や表示面積などの発火条件を満たしたときに発火と見なします。具体的な発火条件に関してはこの後のセクションで説明します。

続きを読む

UICollectionViewのCompositional Layoutsでセル全体にドロップシャドウをつける方法

OGP

はじめに

こんにちは、フロントエンド部WEARiOSブロックの西山です。

iOS 13から登場したCompositional Layoutsを使うことで、App Storeのような複雑なUIが簡単に実現できるようになりました。

登場前は、UICollectionView in UICollectionViewまたは、UIStackView + UIScrollView in UICollectionViewで頑張って実現していたところをUICollectionView1つで実現できます。

一方で、登場前の方法では簡単に出来ていたカスタマイズをCompositional Layoutsで実現しようとすると難しくなるケースが存在しました。その1つに、横スクロールするセル全体にドロップシャドウを付ける方法が挙げられます。

WEARには次のようなUIが存在します。

WEARのシャドウが付いている画面

このキャプチャ画像では少し分かりづらいかもしれませんが、セル全体にドロップシャドウが付いています。このUIをCompositional Layoutsで実現するのが一筋縄ではいかなかったので、WEARでの解決方法を紹介します。

環境

  • Xcode 13.4.1
  • Swift 5.6.1

一筋縄ではいかなかった理由

1. セルを覆うクラスが公開されていない

Compositional Layouts登場前のWEARでは、UIStackView + UIScrollView in UICollectionViewで実現していました。UIStackViewをラップするような形でシャドウ用のViewを用意する方法を取っていたので比較的簡単に実現できていました。

Compositional Layoutsでも似たような方法を取れれば簡単に実現できますが、残念ながらCompositional Layoutsのセルをラップするクラスは公開されていませんでした。

_UICollectionViewOrthogonalScrollerEmbeddedScrollViewは公開されていない

2. セルにシャドウを付けると繋ぎ目からはみ出る

セルのラップクラスにシャドウを付けることは適わなそうなので、セル1つ1つにシャドウを付ける方法を取りました。しかし、この方法ではセルとセルの繋ぎ目から前後どちらかのシャドウがはみ出してしまいうまくいきません。

前後のシャドウがはみ出す

解決方法

mask layerを利用する

セルにシャドウを付ける方法を取りつつmask layerを利用し、はみ出す部分を隠します。mask layerの簡単なおさらいですが、Viewの切り抜きや穴を開ける方法で語られることが多いと思います。

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .darkGray
        
        // 丸のlayerをセンターに置く
        let maskLayer = CAShapeLayer()
        maskLayer.frame = view.bounds
        
        let width: CGFloat = 200
        let height: CGFloat = 200
        let point = CGPoint(x: view.center.x - width / 2, y: view.center.y - height / 2)
        let rect = CGRect(origin: point, size: .init(width: width, height: height))
        let path = UIBezierPath(roundedRect: rect, cornerRadius: width / 2)
        maskLayer.path = path.cgPath
        
        view.layer.mask = maskLayer
    }
}

背景色darkGrayViewに丸のmask layerをセンターに置いたサンプルコードです。上記コードを実行すると次のようになります。

グレーの丸が切り抜かれる

要するにmask layerと重なる部分が表示されるようになります。

mask layerを使用するためのPosition

やりたいことは、初めのセルの右側、中間のセルの左右、最後のセルの左側を隠すことです。

シャドウを隠したいところ

そのため、どのポジションにいるのか判断できるように型を用意しています。

enum Position {
    case first
    case middle
    case last(isSingle: Bool)
}

extension Position {
    init(index: Int, itemCount: Int) {
        switch (index, itemCount) {
        case let (index, count) where (index + 1) == count:
            self = .last(isSingle: index == 0)
        case (0, _):
            self = .first
        default:
            self = .middle
        }
    }
}

ポジションに合わせてmask layerを定義します。

let width = bounds.width
let height = bounds.height

let maskSpace: CGFloat = 50 // 適切な値を指定
var maskLayer: CALayer? = CALayer()
maskLayer?.backgroundColor = UIColor.black.cgColor

switch position {
case .first:
    // 上、左、下にシャドウが表示されるようlayerを被せる
    maskLayer?.frame = .init(x: -maskSpace,
                             y: -maskSpace,
                             width: width + maskSpace,
                             height: height + maskSpace * 2)
case .middle:
    // 上、下にシャドウが表示されるようlayerを被せる
    maskLayer?.frame = .init(x: 0,
                             y: -maskSpace,
                             width: width,
                             height: height + maskSpace * 2)
case let .last(isSingle):
    if isSingle {
        // 隠したくないのでlayerを削除
        maskLayer = nil
    } else {
        // 上、下、右にシャドウが表示されるようlayerを被せる
        maskLayer?.frame = .init(x: 0,
                                 y: -maskSpace,
                                 width: width + maskSpace,
                                 height: height + maskSpace * 2)
    }
}

WEARでは再利用できるようShadowViewを用意しています。

class ShadowView: UIView {
    private var position: Position?

    override func draw(_ rect: CGRect) {
        super.draw(rect)
        drawShadow()
    }

    func updateShadow(for position: Position) {
        self.position = position
        setNeedsDisplay()
    }

    private func drawShadow() {
        guard let position = position else {
            return
        }

        let width = bounds.width
        let height = bounds.height

        let maskSpace: CGFloat = 50
        var maskLayer: CALayer? = CALayer()
        maskLayer?.backgroundColor = UIColor.black.cgColor

        switch position {
        case .first:
        // mask layerのコード省略
        ...

        layer.shadowOffset = .zero
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowRadius = 10.0
        layer.shadowOpacity = 0.6

        layer.mask = maskLayer
    }
}

この方法を取ることでセル全体へのシャドウをつけることが出来ました。

セル全体へシャドウ適用

shadowPathで更に調整

セル全体へシャドウを適用した時点では、若干繋ぎ目が離れているところが気になります。

繋ぎ目に隙間がある

shadowPathを利用することで、もう少し繋がっている様に見せられます。Viewよりも少し広めにshadowPathを引くことで繋ぎ目を可能な限り消します。ShadowViewdrawShadowに手を加えます。

// mask layerのコードを省略しています
private func drawShadow() {
    let width = bounds.width
    let height = bounds.height

    ... 

    let shadowSpace: CGFloat = 10 // 適切な値を指定
    let shadowPath: UIBezierPath
    
    switch position {
    case .first:
        shadowPath = .init(rect: .init(x: 0,
                                       y: 0,
                                       width: width + shadowSpace,
                                       height: height))

        ...
    case .middle:
        shadowPath = .init(rect: .init(x: -shadowSpace,
                                       y: 0,
                                       width: width + shadowSpace * 2,
                                       height: height))

        ...
    case let .last(isSingle):
        if isSingle {
            shadowPath = .init(rect: .init(x: 0,
                                           y: 0,
                                           width: width,
                                           height: height))

            ...
        } else {
            shadowPath = .init(rect: .init(x: -shadowSpace,
                                           y: 0,
                                           width: width + shadowSpace,
                                           height: height))

            ...
        }
    }

    layer.shadowPath = shadowPath.cgPath
    ...

shadowPathを利用したセル全体へのシャドウ適用

完璧とまではいきませんが、shadowPathを利用することでより良くなったのではないでしょうか。 サンプルコードはわかりやすいようにシャドウを濃くしていましたが、実際はもう少し薄いので馴染んで見えます。

さいごに

Compositional Layoutsでセル全体にドロップシャドウをつける方法を紹介しました。同じ様な悩みを抱えている方の参考になれば幸いです。以前は割と簡単だった実装もCompositional Layoutsでは難しいケースが他にもあるので、今後のアップデートで更にカスタマイズしやすくなることを期待しています。

WEARではiOSエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。

hrmos.co

WWDC Extended Tokyo 2022を開催しました #wwdc22 #wwdctokyo

opg

こんにちは、ZOZO CTOブロックの@ikkouです。

WWDC Extendedとは

WWDC Extendedは、WWDCのメインセッション(Keynote)をさらに楽しむためのイベントです。これまでのWWDC Extendedはヤフーが単独で開催していましたが、今年のWWDC Extended Tokyo 2022はヤフーに加え、LINEとPayPay、そして私たちZOZOの4社で運営しました。今回のイベントもApple公式のBeyond WWDCにも掲載されています。

yj-meetup.connpass.com

今年のWWDC Extended

ZOZOで普段開催しているMeetupやTech Talkでは、ZoomとOBSを用いてYouTube Liveでライブ配信し、質疑応答にはSlidoを用いています。

WWDC Extendedでは、YouTube LiveとSlidoを用いている点は同じでしたが、ZoomとOBSではなくStreamYardで用いて実施しました。また、休憩中にはMiroを、イベント終了後にはDiscordを用いた交流など、オンラインイベントを盛り上げる複数の取り組みを実施しました。

当日のアーカイブ動画は公開されていませんが、イベントの雰囲気は先行して公開されているYahoo! JAPAN Tech Blogのレポート記事をご覧ください。

techblog.yahoo.co.jp

ZOZOからはZOZOTOWNアプリ部の@banjunが登壇し、WWDC参加にあたってZOZOで実施している作戦会議やラボ戦略を惜しげなく披露しました。

speakerdeck.com

WWDC22での実際のMiro活用事例や、現地参加した3名のエンジニアによる写真を交えたレポート記事は既に公開しているので、ぜひご覧ください。

techblog.zozo.com

最後に

ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。また、今後もグループ間のシナジー効果を生かしたイベントを開催していきたいと考えています。

一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!

corp.zozo.com

海外出張とオンライン参加で学び、楽しんだ - WWDC22参加レポート

opg

こんにちは、ZOZOTOWNアプリ部の@inokinnです。

inokinn

日本時間の6月7日から11日にかけてWWDC22が開催されました。

今年のハイライトは、iOS 16でのロック画面のアップデートをはじめ、WeatherKitやSwift Charts、Passkeysなどの、数多くの新機能の発表だったかと思います。

今年は去年と一昨年に続いてのオンライン開催に加え、抽選に当選すれば現地であるApple Parkでのパブリックビューイングにも参加できました。そして、なんと幸運にもZOZOからも3名が当選し、現地に赴きました!

本記事では、WWDC22でZOZOのiOSアプリ開発メンバーが取り組んだことを紹介します。また、ラボでAppleのスタッフから得られたフィードバックや、海外出張したメンバーによる現地レポートも可能な範囲で公開します。是非最後までご覧ください。

WWDCについて

WWDC(Worldwide Developers Conference)は、Appleが年に1度開催している開発者向けのカンファレンスです。今年は2019年以来、3年ぶりに現地でもイベントが開催されたので、当選したメンバーは業務の一環として現地参加しました。ZOZOの開発部門では、海外カンファレンスを含むセミナー・カンファレンス参加支援制度が用意されています。

現地で楽しむWWDC22

jun tsutomu tosh

こんにちは、ZOZOTOWNアプリ部の小松、荻野とWEAR部の坂倉です。

コロナ禍になってから完全オンライン開催のWWDCでしたが、なんと今年は一部オフライン開催もありました。案内としては、6月6日のKeynoteやPlatforms State of the UnionなどをApple Parkでライブビューイングできるとのことでした。

現地参加のスケジュールは以下の通りです。

時間(PST) コンテンツ 場所
7:00 AM チェックイン Apple Park Visitor Center
8:00 AM 朝食 Apple Park - Caffè Macs
10:00 AM Keynote Apple Park
12:00 PM 昼食 Apple Park - Caffè Macs
1:00 PM Platforms State of the Union Apple Park
2:30 PM Meet the Teams Apple Park - Caffè Macs
4:30 PM Apple Design Award Apple Park

また、今年は参加記念品として以下のものをもらえました。個人的にお気に入りなのは、Swiftロゴの入ったトートバックです。MacBook Proも入るサイズで愛用しています。

WWDC22 参加記念品 swift

6月5日 - イベント前日

6月5日はイベント前日ではありますが、アーリーチェックインが可能になっており、先行でDeveloper Centerのオープンハウスも開催されていました。

会場はお祭りさながらの雰囲気で非常に盛り上がっていました。

Special Event at Apple Park前日の会場の様子

早々にチェックインを済ませ、Developer Centerへ。

Developer Center

Developer Centerはエンジニアやデザイナーが交流したり学ぶための施設とのことですが、Apple Storeさながらのお洒落な部屋がいくつも用意されていました。

実際にコードを書いて学べるワークショップ用の部屋や、壁に巨大なホワイトボードが設置されたUI設計用の部屋、製品設計用の部屋などがありました。あまりの綺麗さに一度はここで仕事をしてみたいと思いました(ちなみに各部屋はこれまでのmacOSの名前が付けられています)。

Developer Centerの部屋の様子

一番驚きだったのが、Big Surと呼ばれる放送スタジオです。

そこはまるで映画館のような空間になっており、小さな文字もしっかり読める高解像度の超巨大モニターや、色々な角度から音を鳴らすことができるサウンドシステムには度肝を抜かれました。

放送スタジオということで、ここでAppleが観客の前でKeynoteを催すことはないのかもしれませんが、この空間で一度Keynoteを観てみたいと思いました。

Developer CenterのBig Surスタジオ

6月6日 - イベント当日

apple park live viewing

ライブビューイング

6月6日、イベント当日。朝の入場待ちの列ではコーヒーが配られ、これから初公開のApple Parkへと足を踏み入れる人たちの熱気に包まれていました。

Appleのスタッフに歓迎されながら道を進むと、ついにApple Parkが姿を現します。汚れひとつない全面ガラス張りの外観は息を呑む美しさでした。

apple park office

Apple Parkの中に入って朝食を受け取り、大きなスクリーンが置かれている広場であるCaffè Macsへ進みます。

メインスクリーンの正面の部分は窓ガラスが可動式になっており、屋内・屋外どちらからでもスクリーンが見られるように開かれていました。こんなに大きな窓ガラスが本当に動くのか…と規格外の大きさに圧倒されてしまいます。

Keynoteが始まるまでの時間ではAppleのスタッフがいたるところで踊っていたりとお祭りムードでした。

画面が暗転し、ついにKeynoteのライブビューイングが始まると思いきや、そこに現れたのはなんとティム・クックとクレイグ・フェデリギ!

座っていた参加者は全員立ち上がり、先ほどまでの熱気がより一層強まりました。

tim cook craig federighi

Keynote本編では、iOS 16や新型MacBook Airをはじめ、さまざまな新機能の発表がありました。新しい機能が発表されるたびに起こる拍手や歓声で喜びを共有できるのは、現地ならではの良さだなぁと感じました。

ちなみに、Keynoteのライブビューイングの中で最大の盛り上がりを見せたのは、超高速で移動するフェデリギ氏がスーパースローで髪を掻き上げているシーンでした(笑)。

Keynoteが大盛況の中終わると、お昼休憩を挟んでPlatforms State of the Unionが始まります。

State of the UnionではXcode CloudやSwiftUI、iOS 16のアップデートに伴うWidgetKitなど、新機能の紹介が行われました。ここでもKeynoteと同様に、リアクションが会場全体から漏れてきます。

ライブビューイングが終了した後は、Apple Park内の公開されているエリアを探索しました。

Appleのスタッフによると、3階からのCaffè Macsの景色がApple Parkで1番美しいスポットとのことでした。3階まで登ると円形になっている建物の全体を見渡すことができ、1番というのも納得の光景が広がっていました。

view from Caffè Macs

また、探索中にAppleのスタッフからAppleの環境や運動に対する取り組みに関する話を聞くことができました。

Apple Parkは建物部分が敷地全体の20%しかないらしく、緑との共生を大切にしているとのことでした。また、こうして緑を多くすることによって積極的に外に出るような環境を作り、他のチームとの交流や歩きながらのミーティングを促進しているらしいです。

Apple Park内にはバスケットボールのコートやテニスコート、サッカー場などがあり、日本では絶対にできないような土地の使い方を見ると、さすがアメリカだなぁと感じます。

Meet with Teams

Platforms State of the Unionが終わると、Caffè MacsではMeet with Teamsというイベントが行われていました。ここではさまざまな分野のAppleのエンジニアが常駐しており、気軽に雑談ができました。実際に、自分はXcodeエンジニアとFitnessエンジニア、UIKitエンジニアと話しました。

Xcodeエンジニアとは、Xcode AppはXcodeで作られているのか、Appleのエンジニアだったら常に最新のMacに交換し放題なのかといった話をしました。こういったカジュアルな雑談ができるのはラボとの大きな違いだった思います。専門的な話になるとラボを勧められるといった雰囲気でした。

オンラインで楽しむWWDC22

海外出張した3名以外の、ほとんどのメンバーはオンラインでWWDC22に参加しました。開催期間中、ZOZOTOWNアプリ部のメンバーは、現地の時間に合わせて日本時間2:00〜11:00を勤務時間としていました。

チーム内で情報を共有するため、毎日1回、ビデオ通話でのミーティングを行っていました。また、分担して視聴したセッションのサマリや、ラボやラウンジで得た情報はMiroに一元管理して共有していました。色々な情報が共有されたため、WWDC22の全日程が終了した後にはボードの様子は以下のようになっていました。

WWDC22終了後のMiroの様子

このやり方は去年のノウハウを活かしたもので、下記のWWDC21参加レポートにより詳しく公開しているので、よろしければこちらもご覧ください。

techblog.zozo.com

Digital Lounges

WWDC22では、去年に引き続きオンラインならではの取り組みが実施されていました。

「Digital Lounges」はその1つで、Slackを用いて、Appleのエンジニアやデザイナーにチャットで質問することが出来ました。

質問以外にも、ラウンジ上では「Trivia Night」というイベントも開催されていました。これは、Appleプラットフォームに関するトリビアクイズが出題されるというものです。プログラムの実行結果に関する問題や「Apple社の祝日はいつ?」といったApple愛を試されるクイズも出題されました。

Trivia Night

Challenges

「Challenges」も、オンラインならではのコンテンツでした。こちらは、いくつかの提示されたお題の中から好きなものに取り組むことが出来るコンテンツです。取り組んだ結果をラウンジでAppleのスタッフや世界中のiOSアプリ開発者たちに公開して意見をもらったり、SNSで共有して盛り上がったりすることが出来ます。お題にはバリエーション豊かなものが毎日追加されるので、興味のあるものに挑むのが楽しかったです。

私も「Pixel perfect design」という、アプリのアイコンをピクセルアートで表現するお題に挑戦し、弊社アプリ「ZOZOTOWN」のアイコンを描いてみました!

Pixel perfect designのフィードバック

Digital Loungesで見ていただいたところ、お褒めの言葉と同時に「文字のラインを2pxではなく1pxで表現してみては?」というフィードバックをいただけました。得られたフィードバックを反映し、ピクセルアートをこのように改善することが出来ました。

TAKE 1 TAKE 2 TAKE 3
ZOZOTOWNピクセルアートTAKE 1 ZOZOTOWNピクセルアートTAKE 2 ZOZOTOWNピクセルアートTAKE 3

この作品はApple公式サイトの WWDC22 Daily Digest: Wednesday にも取り上げていただけました!

WWDC22 Daily Digest: Wednesday

Source: https://developer.apple.com/news/?id=pcfa7nkx

Labs & Sessions

今年も、ラボではAppleのスタッフからフィードバックを得ることが出来ました。WWDC22に参加したZOZOメンバーから、それぞれが参加したラボで得たフィードバックや、視聴したセッションで得た内容を一部紹介します。

Design Lab × FAANS

memoji_nakaji こんにちは、FAANS部iOSチームの中島です。Design LabでAppleのデザイナーにFAANSアプリのフィードバックを頂いたので、紹介いたします。

投稿フローに対するフィードバック

FAANSはWEARと同様にコーディネートを投稿でき、投稿写真の明るさ調整の機能があります。スライダーで「明るさ、コントラスト、彩度」の数値を変更するのですが、この機能に対し「プリセットを提供し、ユーザーが簡易的に明るさを調整できるようにしてはどうか」というフィードバックをいただきました。投稿フローにかかる時間の短縮方法を検討していましたので、今後の改善の参考にしたいと思います。細かいフィードバックをいただいたものの、コーディネート投稿フロー全体として完成度が高い、といっていただけたので今後の開発の励みになりました。

明るさ調整

着用アイテムを登録する機能に対するフィードバック

投稿するコーディネートに着用アイテムを紐付けする機能があります。具体的にブランドをどのように選べばいいのか、という質問を受けました。品番/バーコードでの登録、お気に入り(クローゼット)からの登録、カテゴリ、カラー、ブランドを入力して該当の商品を一覧から探して登録、の3種類の方法があります。選択肢が多いことはユーザーを迷わせる原因になると思いましたので改善していきたいです。

着用アイテムを登録

通知の活用

FAANSはショップスタッフが主なユーザーです。コーディネート投稿機能に加え、在庫取り置きの機能があります。今のUIだとコーディネート投稿の結果確認がメインになっており、店舗取り置きの導線が少しわかりにくいという問題点に対し、どのように思うか質問しました。店舗取り置きがある場合、見逃さないようにする必要があるのでアイコンに赤い丸をつける等、通知という形でわかれば問題ないという回答をいただきました。

Design Labを利用した感想

FAANSアプリはWEARやZOZOTOWNとも連携する部分があり、英語での説明も相まって、そもそもどのようなアプリなのか説明するのが難しかったです。ユーザーが限られているというのはあるのですが、対象のユーザーでない人でも直感的に理解、操作できるように改善していきたいです。Design Labを利用したのは初めてでしたが、とても有意義なラボでしたので来年も利用したいです。

WeatherKitはWEARを拡張するか

しょうご

こんにちは、WEAR部iOSブロックのしょうごです。個人的にファッションと天気の相関性について、以前より注目をしていました。

なぜなら、冷夏や暖冬といった異常気象の季節には、季節物の販売不振に陥るケースがあります。そのため、ファッションコーディネートアプリのWEARとしても、ファッション業界の動向は注視する必要があると思います。そこで、異常気象によるWEARへの悪影響を想定した場合、アパレル商品の流通量の低下の影響から、ユーザーログイン頻度の低下などは考えられると思います。故に、異常気象の影響を少しでも緩和できる仕組みが備わっていれば尚良いと思っており、以下の様な事を考えていました。

  • WEAR自体が天気情報をユーザーにPUSH通知を用いて知らせ、WEARアプリに誘導する
  • 多くの人々は外出する前に天気予報を確認するため、アプリのユーザーにとってもメリットがある
  • アプリを開いた後ユーザーの位置情報をもとに、気温や湿度情報などからよく使われる服をレコメンドする
  • 結果として、人々のファッションの悩みを解決するというWEARのミッションも果たせるかもしれない

今回発表のあった内容によるとWeatherKitは、非常に詳細な気象データを取得できるようです。詳細な気象データを取得可能な理由として、高解像度の気象モデルと機械学習および予測アルゴリズムを使用し導き出しているとセッション中で説明がありました。取得できる情報の詳細はMeet WeatherKit - WWDC22をご参照下さい。

読者の皆さんはWeatherKitに関してどの様な印象をお持ちになりましたか? 技術は使い方次第で可能性は無限大です。WEARに関して考えてみると、個人的にWeatherKitとの親和性は高いと考えています。なぜなら、WeatherKitの精度次第では、天気情報をベースとした新たなファッションの提案が可能になると考えたからです。また、既存のWEARを拡張してユーザーとのタッチポイントを増やす事ができるというシナジーも期待できます。今後WEARならではの新たなサービスを生み出す事も、可能かもしれません。そして、コーディネートと天気の親和性が高いファッション業界に、ZOZOらしくテクノロジーの力を用いて一石を投じる可能性もあると考えています。

まとめ

本記事では、WWDC22の参加レポートをお伝えしました。

海外出張で現地参加したメンバーも、オンラインでリアルタイムに情報をキャッチアップしたメンバーも、それぞれに実りあるイベントでした。今回得られた知見やフィードバックをもとに、より良いサービスの向上に努めていきたいと思います!

さいごに

ZOZOでは、一緒にモダンなサービス作りをしてくれる仲間を募集しています。ご興味のある方は、以下のリンクから是非ご応募ください!

corp.zozo.com

https://hrmos.co/pages/zozo/jobs/0000012hrmos.co

https://hrmos.co/pages/zozo/jobs/0000023hrmos.co

https://hrmos.co/pages/zozo/jobs/0000177hrmos.co

【オンラインMeetup イベントレポート】ZOZO Tech Talk #6 - iOS

こんにちは、ZOZO CTOブロックの@ikkouです。

ZOZOでは、5/16にZOZO Tech Talk #6 - iOSを開催しました。

zozotech-inc.connpass.com

本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。

第6回はネイティブアプリ開発の中で、特にiOSにフォーカスした内容を発表しました。

登壇内容 まとめ

弊社の社員2名が登壇しました。

  • Hapticをカスタマイズしてみよう (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 遠藤 万里)
  • Apple silicon導入のウラガワテックブログに盛り込めなかった話、公開します (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 小松 悟)



最後に

ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。

一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!

corp.zozo.com

WEARに動画投稿を実装した際にハマった事とその解決策

OGP

はじめに

こんにちは。WEAR部iOSチームの坂倉です。先日、WEARにコーディネート動画の投稿機能を実装しました。

iOSで動画を扱うにはAVFoundationを使う必要がありますが、原因がわかりにくいエラーを引き起こすことが多々あり、実装になかなか苦労しました。

この記事では、動画投稿の開発中に起きた問題とその解決法をお伝えします。

WEARの動画投稿には以下の機能が存在します。

  • 動画を選択する
  • 動画をプレビューする
  • 動画に付与する音楽を選択する
  • 動画に付与する音楽の範囲を指定してトリミングする
  • 動画に関する情報を付与する
  • 動画と音楽をミックスしてエンコードする
  • 完成した動画を投稿する

WEARのコーディネート動画投稿フロー

これらを実装する中で、2つの問題に直面しました。

  • 特定の動画が原因不明のエラーでエンコードできない
  • 音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計

それぞれの解決方法を下記にてお伝えいたします。

特定の動画が原因不明のエラーでエンコードできない

動画といっても色々な種類があり、この動画のエンコードは問題ないが、あの動画はエラーが出てしまうといったことが多々ありました。

ここでは、その時の対処法について解説します。

ちなみに、WEARで使用しているエンコードのコードは以下の通りです(一部省略)。

エンコードコード(クリックで展開)

// 動画と音楽のURLからAVURLAssetを生成
let videoURLAsset: AVURLAsset = .init(url: videoPath)
let audioURLAsset: AVURLAsset = .init(url: audioPath)
guard let videoAssetTrack = videoURLAsset.tracks(withMediaType: .video).first,
      let audioAssetTrack = audioURLAsset.tracks(withMediaType: .audio).first
else {
    return
}
// AVURLAssetから動画+音楽を追加し、AVAssetExportSessionに必要なコンポジションを作成
let composition: AVMutableComposition = .init()

// 動画をAVMutableCompositionに追加
guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
    return
}
try? videoTrack.insertTimeRange(videoAssetTrack.timeRange, of: videoAssetTrack, at: .zero)

// 範囲を指定し音楽をAVMutableCompositionに追加(ここでは音楽の長さは動画の長さと同じにする)
guard let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
    return
}

let audioTimeRange: CMTimeRange = .init(start: .zero, end: videoTrack.timeRange.end)
try? audioTrack.insertTimeRange(audioTimeRange, of: audioAssetTrack, at: .zero)


// 動画を回転させるためにAVMutableVideoCompositionLayerInstructionを生成する
let videoCompositionLayerInstruction: AVMutableVideoCompositionLayerInstruction = .init(assetTrack: videoTrack)
let transform = makeTransform(with: videoTrack)
videoCompositionLayerInstruction.setTransform(transform, at: .zero)


// AVMutableVideoCompositionInstructionに動画の時間と回転情報を渡す
let videoCompositionInstruction: AVMutableVideoCompositionInstruction = .init()
videoCompositionInstruction.timeRange = videoTrack.timeRange
videoCompositionInstruction.layerInstructions = [videoCompositionLayerInstruction]


// 動画の解像度やframeDurationカラー情報をAVAssetExportSessionに渡すためのAVMutableVideoCompositionを生成
let videoComposition: AVMutableVideoComposition = .init()


// iOSの画面収録で撮った動画がiOS 14でエンコードできないで解説します
videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2


// 写真アプリでトリミングした動画がエンコード出来ないで解説します
let fps = max(videoAssetTrack.nominalFrameRate, 1.0)
videoComposition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(fps))
videoComposition.renderSize = CGSize(width: 1080.0, height: 1920.0) // 解像度を指定
videoComposition.instructions = [videoCompositionInstruction]


// AVMutableCompositionとAVMutableVideoCompositionで動画+音楽ソース+解像度などの詳細を渡し、動画を生成
guard let assetExportSession: AVAssetExportSession = .init(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
    return
}
guard let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first else {
    return
}
assetExportSession.videoComposition = videoComposition
// AVMutableVideoCompositionInstructionにも指定しているがここでも指定しないとエラーが出る
assetExportSession.timeRange = videoTrack.timeRange
assetExportSession.outputFileType = .mp4
let videoFilePath = "\(documentPath)/tmp.mp4"
assetExportSession.outputURL = URL(fileURLWithPath: videoFilePath)
assetExportSession.shouldOptimizeForNetworkUse = true
assetExportSession.exportAsynchronously {
    switch assetExportSession.status {
    case .completed:
        print(assetExportSession.outputURL!)
    @unknown default:
        return
    }
}

iOSの画面収録で撮った動画がiOS 14でエンコードできない

色々な動画のエンコードを試す中で、どうしてもエンコードできない動画がありました。それは、iOSの画面収録で撮影した動画です。

AVAssetExportSessionはエラーを出力してくれますが、エラーを見ても原因を突き止めるのが困難な内容でした。

Error Domain=AVFoundationErrorDomain Code=-11800 "操作を完了できませんでした" UserInfo={NSLocalizedFailureReason=原因不明のエラーが起きました(-12212), NSLocalizedDescription=操作を完了できませんでした, NSUnderlyingError=xxxx {Error Domain=NSOSStatusErrorDomain Code=-12212 "(null)"}}

そのため、エラーコードに注目しました。-12212 と表示されていたので調べたところkVTColorCorrectionPixelTransferFailedErrというエラーだとわかりました。

stackoverflow.com developer.apple.com

色に問題ありということで、問題の動画をQuickTime Playerのムービーインスペクタで確認しました。

すると、エンコードできる動画と比べ、画面収録で撮った動画はTransfer FunctionがsRGBとなっており、Appleの Setting Color Properties for a Specific Resolution に記述されている設定例に無い値になっていました。

エンコードできる動画とできない動画のムービーインスペクタ

そのため、「この動画はAVAssetExportSessionに対応していない」という仮説を立て、色指定を変更することでエンコード出来るか試してみました。

AVVideoCompositionは、動画の色空間情報を設定するためのプロパティを3つ持っています。

これらを、Appleの Setting Color Properties for a Specific Resolution に記述されている例を元に設定してみました。(10-bit wide gamut HDはAVVideoSettingsのドキュメントコメントに記述されています)。

なんと、OSのバージョンによって違いが出る結果になりました。

OS別エンコード確認表(○は可、×は不可)

設定無 HD SD wide gamut HD 10-bit
wide gamut HD
iOS 15 ○(HD化) ○(HD化) ○(HD化)
iOS 14 × × ×
iOS 13 ×

iOS 15は、どのパターンでもエンコードできました。iOS 13/14は、HD/wide gamut HD以外だとエンコードエラーになりました。

面白いのは、iOS 15の場合、設定なし/SD/10-bit wide gamut HDの場合、色空間がHDと同じになることがわかりました。この結果を見る限り、iOS 15は変換できなかった場合システム側でHDに色を揃えていそうですね。

この結果を見て、HD(Rec.709)が全ての対応OSでもエンコードできて一般的な規格である事から、WEARはHDの設定を使用する事にしました。

// For HD colorimetry, specify
let videoComposition: AVMutableVideoComposition = .init()
videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2

ちなみに、動画の色指定に関して丁寧に説明しているAppleのドキュメントがあるので一見の価値ありです。

developer.apple.com

写真アプリでトリミングした動画がエンコードできない

写真アプリでトリミングした時間が短い動画を指定するとエンコードエラーになる事象もありました。デバッグログを見ると以下のようなエラーが出ていました。

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[AVAssetExportSession setVideoComposition:] video composition must have a positive frameDuration'

CMTimeScaleは整数であることが求められますが、あまりに短い動画だとnominalFrameRateで得られるフレームレートは1を下回ることもあります。 なので、最低値を1.0にして対応しました。

let fps = max(videoTrack.nominalFrameRate, 1.0)
videoComposition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(fps))

動画をmp4で出力する場合、mp3を使うと出力できない問題

Appleのドキュメントには記述が見つからなかったのですが、mp3の音楽ファイルを取り込んでmp4の形式で動画を出力する場合エラーになりました。

StackOverflowによると、movとcaf以外の形式ではmp3を使うことはできないようです。

stackoverflow.com

そのため、WEARではm4aのみを使いmp3は使わないようにしています。

AVMutableVideoCompositionLayerInstructionは、AVAssetTrackを使って生成するとエンコード出来ない場合がある

特定の動画で、なぜかエンコードできないことがありました。エラーの内容は以下の通り。

Error Domain=AVFoundationErrorDomain Code=-11841 "操作が停止しました" UserInfo={NSLocalizedFailureReason=ビデオを作成できませんでした。, NSLocalizedDescription=操作が停止しました, NSUnderlyingError=... {Error Domain=NSOSStatusErrorDomain Code=-17390 "(null)"}}

結論から言うと、AVMutableVideoCompositionLayerInstructionを生成するときに渡していたassetTrackに原因がありました。

🙅‍♀️な例

 guard let videoAssetTrack = videoURLAsset.tracks(withMediaType: .video).first else { return }
let videoCompositionLayerInstruction: AVMutableVideoCompositionLayerInstruction = .init(assetTrack: videoAssetTrack)

🙆‍♂️な例

guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { return }
let videoCompositionLayerInstruction: AVMutableVideoCompositionLayerInstruction = .init(assetTrack: videoTrack)

AVMutableVideoCompositionLayerInstructionのイニシャライザを見ると、assetTrackの型がAVAssetTrackだったので間違ってしまいましたが、AVMutableCompositionのaddMutableTrackのAVMutableCompositionTrackを指定するのが正でした(ちなみにAVMutableCompositionTrackの親の親はAVAssetTrack)。

気付きづらいのが、全ての動画がエンコードできないというわけではないということです。完全に原因を特定できてはいませんが、写真アプリ以外で編集した動画はこの現象が起こりやすい印象でした。

StackOverflowに対処法があったことで気づくことができましたが、これはなかなかの罠ですね。皆さんもご注意ください。

stackoverflow.com

音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計

WEARの動画投稿には、動画に付与する音楽をトリミングできる画面が存在します。

波形で枠内だけを着色する

この画面には、スクロールを止めたタイミングで音楽のループ再生に合わせて枠内(UIScrollViewのスクロール領域のみ)の波形をアニメーション付きで着色する実装が求められました。

開発当初はなかなか上手い実装方法が思い浮かびませんでした。

「音楽のループ再生とアニメーションをどう同期させるか?」「波形画像の一部だけを着色するにはどんな方法でやるのがシンプルか?」「スクロールを邪魔せずどう実装するか?」などを一つ一つ考慮した結果、AVQueuePlayer、AVPlayerLooper、UIGraphicsImageRenderer、UIViewのmask、CAKeyframeAnimationを組み合わせることで実装できました。

踏んだ手順は以下の通りです。

  1. AVQueuePlayer+AVPlayerLooperでループ再生を実装
  2. UIScrollViewに波形の画像を入れる
  3. スクロールが止まったタイミングで音楽を再生しスクロール量を元に枠内の波形を切り出す
  4. 切り出した波形の画像をアニメーション用のViewのmaskに追加
  5. 音楽再生のタイミングでCAKeyframeAnimationを使って波形をアニメーション付きで塗る

各項目を最小限のコードで説明します。

1. AVQueuePlayer+AVPlayerLooperでループ再生機能を実装する

まず、音楽を繰り返し再生する必要があるためAVQueuePlayer+AVPlayerLooperでループ再生を実装します(AVAudioEngineを使う方法もありますが今回は再生だけで良いのでAVQueuePlayerを使いました)。

また、音楽の再生が完了するたびに着色を初めからやり直したいため、NSKeyValueObservationを使ってAVPlayerLooperのloopCountの状態を監視しています。

final class AudioPlayer {
    private let asset: AVAsset
    private let playerItem: AVPlayerItem
    private let player: AVQueuePlayer
    private var playerLooper: AVPlayerLooper?
    private var playerLooperObservation: NSKeyValueObservation?

    init(withAudioFilePath audioFilePath: URL) {
        asset = .init(url: audioFilePath)
        playerItem = .init(asset: asset)
        player = AVQueuePlayer(items: [playerItem])
    }

    // rangeは音楽の再生範囲(秒)
    func play(range: ClosedRange<Double>, completion: @escaping (()) -> Void) {
        player.removeAllItems() // 再生範囲を変えるにはAVPlayerLooperを作り直す必要があるためリセット。これがないとクラッシュする。
        playerLooper = AVPlayerLooper(
            player: player,
            templateItem: playerItem,
            timeRange: CMTimeRange(range: range, timescale: asset.duration.timescale)
        )
        player.play()
        playerLooperObservation = playerLooper?.observe(\.loopCount, options: [.new]) { playerLooper, _ in
            guard playerLooper.loopCount > 0 else { return }
            completion(())
        }
    }
}

extension CMTimeRange {
    init(range: ClosedRange<Double>, timeScale: CMTimeScale) {
        let start: CMTime = .init(seconds: range.lowerBound, preferredTimescale: timeScale)
        let end: CMTime = .init(seconds: range.upperBound, preferredTimescale: timeScale)
        self = .init(start: start, end: end)
    }
}

2. UIScrollViewに波形の画像を入れる

次に、音楽の波形画像をUIScrollViewに追加します。

UIScrollViewに入れた波形は、UIScrollViewのframeから出ても描画したいので、scrollView.clipsToBounds = false にします。

UIScrollViewの図

final class AudioRangeView: UIView {
    @IBOutlet private var scrollView: UIScrollView!
    
    override func awakeFromNib() {
        super.awakeFromNib()
      
        guard let waveFormImageView: UIImageView = .init(image: waveFormImage) else { return } 
        scrollView.addSubview(waveFormImageView)
        scrollView.contentSize = CGSize(
            width: waveFormImageView.bounds.size.width,
            height: waveFormImageView.bounds.size.height
        )
        scrollView.clipsToBounds = false // スクロールバーのframe以外のcontentViewを描写する
    }
}

3. スクロールが止まったタイミングでスクロール量を元に枠内の波形を切り出す

次は、枠内の波形のみを着色するためUIScrollViewの枠内に入っている波形画像を切り出します。

波形の着色は、スクロールが止まった時に行うため、UIScrollViewDelegateのscrollViewDidEndDeceleratingとscrollViewDidEndDragging上で行います。

スクロールが止まったタイミングで着色する

scrollView.contentOffset.x(スクロール量)を使って枠内の波形のポジションを取得しUIGraphicsImageRendererで切り出します。

final class AudioRangeView: UIView {
    // 以下略
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let cropImage: UIImage = UIGraphicsImageRenderer(size: scrollView.bounds.size).image { context in
            waveFormImageView.image?.draw(at: CGPoint(x: -scrollView.contentOffset.x, y: .zero))
        }
        animationView.startAnimation(image: image, animationDuration: 10.0)
    }
}

4. 切り出した波形の画像をアニメーション用のViewのmaskに追加

次に、UIScrollViewの上に同サイズのアニメーション用のViewを置きます。

このViewに先ほど切り出した波形画像を渡し、これを着色することで、UIScrollViewの中に収まった波形だけが塗られていくように見せます。

波形画像を着色するにはmaskを利用します。maskに切り出した波形画像を入れます。

final class WaveFormColoringAnimationView: UIView {
    @IBOutlet private var liquidView: UIView!

    func startAnimation(image: UIImage, animationDuration: Double) {
        mask = UIImageView(image: image)
    }
}

そして、着色用のViewをアニメーション用のViewの一番上に貼ります。

アニメーション用のViewのxib

このViewには波形に着色したい色をアニメーションを開始するタイミングでbackgroundColorに指定します。

func startAnimation(image: UIImage, animationDuration: Double) {
    mask = UIImageView(image: image)
    liquidView.backgroundColor = .blue
}

次に、CAKeyframeAnimationを用いて切り出した波形を左から右に着色します。

durationには音楽の再生時間を指定します。こうすることで再生と共にアニメーションが進むようになります。

final class WaveFormColoringAnimationView: UIView {
    private var liquidView: UIView!
    
    func startAnimation(image: UIImage, animationDuration: Double) {
        mask = UIImageView(image: image)
        liquidView.backgroundColor = .blue

        let animation: CAKeyframeAnimation = .init(keyPath: "position.x")
        animation.values = [-(liquidView.bounds.size.width * 0.5), liquidView.bounds.size.width * 0.5]
        animation.duration = CFTimeInterval(animationDuration)
        animation.isRemovedOnCompletion = false
        liquidView.layer.add(animation, forKey: "coloringAnimation")
    }
}

ループ再生が終わったタイミングでアニメーションを止めたいので、そのためのメソッドも用意しておきましょう。

final class WaveFormColoringAnimationView: UIView {
    // 以下略
    func stopWaveFormColoringAnimation() {
        liquidView.layer.removeAllAnimations()
        liquidView.backgroundColor = .clear
        mask = nil
    }
}

5. 再生と同時にアニメーションを実行して枠内の波形を着色する

あとはスクロールが止まったタイミングで、音楽の再生と着色処理を同時に実行すれば、ループ再生中、枠内の波形だけ着色します。

アニメーションの時間指定には、音楽の再生時間と同じ値を入れるのをお忘れなく。

final class AudioRangeView: UIView {
    private let audioPlayer: AudioPlayer
    @IBOutlet private var animationView: WaveFormColoringAnimationView!

    override init(frame: CGRect) {
        super.init(frame: frame)
        audioPlayer = AudioPlayer(url: audioFilePath) // 音楽のローカルパスを指定
    }

    // 以下略

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let cropImage: UIImage = UIGraphicsImageRenderer(size: scrollView.bounds.size).image { context in
            waveFormImageView.image?.draw(at: .init(x: -scrollView.contentOffset.x, y: .zero))
        }

        let playRange: ClosedRange<Double> = 0.0 ... 10.0
        let animationDuration = playRange.upperBound - playRange.lowerBound

        animationView.startAnimation(image: cropImage, animationDuration: animationDuration)
        audioPlayer.play(range: playRange, completion: { [weak self] in
            guard let self = self else { return }
            DispatchQueue.main.async {
                self.animationView.stopWaveFormColoringAnimation()
                self.animationView.startAnimation(image: cropImage, animationDuration: 10.0)
            }
        })
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        guard !decelerate else { return }
        // 同様の処理
    }
}

さいごに

AVFoudationは扱いづらい事もありますが、段々慣れてくると非常に楽しくなってきますね。この記事が、誰かの動画開発の一助になれば幸いです。

ぜひ、お気に入りのコーデ動画を投稿してくれると嬉しいです。よろしくお願いします。

WEARでは、今後もどんどん動画の開発を進めていきます。ご興味のある方は以下のリンクからぜひご応募してください!

hrmos.co

Combineの非同期処理をSwift Concurrencyのasync/awaitで書き換えてみた

OGP

こんにちは、FAANS部の中島 (@burita083) です。2021年10月に中途入社し、FAANSのiOSアプリの開発を行なっています。

FAANSの由来は「Fashion Advisors are Neighbors」で、「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」です。現在正式リリースに向けて、WEARと連携したコーディネート投稿機能やその成果を確認できる機能など開発中です。

はじめに

FAANS iOSでは非同期処理にCombineを利用しています。Combine自体は本記事では詳しく解説をしませんが、RxSwiftを利用したことがある方なら特に違和感なく使えるかと思います。全く馴染みがない場合だと覚えることも多く、難しいところもあるかと思いますので、Swift Concurrencyを利用する方が理解しやすいかもしれません。ただし、ViewとPresenterの値のバインディング処理にも利用していますので、FAANS iOSでは当面、Combineも利用していくと思われます。

今回、async/awaitで書き換えた理由として、主に2つの理由があります。

  • 非同期処理をシンプルに書けるようになるため

Combineのコードは、コールバックで受け取る必要があり、コールバックの中でさらに別のAPIを叩く場面もあります。async/awaitで手続型のように書けるので、シンプルな記述が可能です。本記事で実際のコード例を元に説明します。

  • Swiftのアップデートに追従しつつ、チームとして継続的に新しい技術に触れることで成長していきたいため

URLSession等、Apple標準のAPIでasync/awaitがすでに使われており、今後も様々な機能がアップデートされます。キャッチアップした内容を業務で積極的に活用できる環境づくりをチームで心がけています。

本記事ではCombineでの非同期通信の処理に対しasync/awaitで書き換えたユースケースを紹介し、実装のポイント等、説明します。

続きを読む
カテゴリー