Slide 1

Slide 1 text

メタバースプラットフォーム開発 におけるSwiftUIの活用とTips クラスター株式会社 / プラットフォーム事業本部 / ソフトウェアエンジニア Ahi To / TAAT

Slide 2

Slide 2 text

Cluster, Inc. 2 概要 ● メタバースプラットフォームでどのようにSwiftUIを活用して 開発を加速させているか ● SwiftUIで苦戦したりUIKitと併用した部分のTips

Slide 3

Slide 3 text

Cluster, Inc. 3 自己紹介 Ahi To / TAAT クラスター株式会社 / ソフトウェアエンジニア メタバースにチャレンジしてみたくて、2023年1 月からクラスターにジョイン! taatn0te @TAAT626 TAATHub

Slide 4

Slide 4 text

Cluster, Inc. 4 clusterとは? VRからスマホまで遊べるメタバースプラットフォーム

Slide 5

Slide 5 text

Cluster, Inc. 5 clusterとは? マルチプラットフォーム対応 iOS Android macOS Windows Desktop / VR Meta Quest 2 実はこれらのプラットフォーム向けのクライアントアプリケーションを毎週リ リースしている! PC,モバイル,VRに対応したマルチプラットフォームアプリのリリースフロー

Slide 6

Slide 6 text

Cluster, Inc. 6 アバターワーク チームのバーチャル仕事部屋 cluster内でMTGや雑談をしたり、その場で ワールドを 改 築 したり、リモートワークでもコ ミュニケーションが取りやすい! クラスターに入社して 1ヶ月経ってみて アバターでWeb会議 Web会議もアバターで参加できる!アバター ワーク感があって良い!

Slide 7

Slide 7 text

7 メタバースプラットフォームでの iOSエンジニアの活躍領域

Slide 8

Slide 8 text

Cluster, Inc. 8 iOSエンジニアの活躍領域 outroom inroom バーチャル空間内の体験 バーチャル空間外 の非同期な体験

Slide 9

Slide 9 text

Cluster, Inc. 9 iOSエンジニアの活躍領域 outroom inroom cluster Unity-iPhone UnityFramework SwiftPackage xcframework Unity as a LibraryをSwiftPM経由で導入してiOSビルド環境を改善した話 Unity as a Library Unityで構築したアプリケーションをネイティブアプリの ライブラリとして扱える

Slide 10

Slide 10 text

Cluster, Inc. 10 iOSエンジニアの活躍領域 iOSエンジニアは、バーチャル空間外の非同期的 な体験の機能開発を担当 outroom SwiftUIで苦戦したり、UIKitを併用する必要のあった部 分について紹介 clusterではiOS 15.0以上をサポートしており、新規画面 では積極的にSwiftUIを使用しているが、まだまだUIKit の部分が多く、SwiftUIだけではできない部分もあるの で、SwiftUIとUIKitを併用している

Slide 11

Slide 11 text

11 SwiftUIのTips

Slide 12

Slide 12 text

Cluster, Inc. 12 Tips SwiftUIのTabViewでインタラクティブなタブインジケーターを作る SwiftUIでImageをピンチイン・ピンチアウト・ダブルタップでズームさせる SwiftUIでTruncated Text ※本資料ではコードを抜粋しているため、詳細なコードは note記事やリポジトリを参照

Slide 13

Slide 13 text

Cluster, Inc. 13 Tips SwiftUIのTabViewでインタラクティブなタブインジケーターを作る SwiftUIでImageをピンチイン・ピンチアウト・ダブルタップでズームさせる SwiftUIでTruncated Text

Slide 14

Slide 14 text

Cluster, Inc. 14 SwiftUIのTabViewでインタラクティブなタブインジケーターを作る

Slide 15

Slide 15 text

Cluster, Inc. 15 TabViewでの基本的なページング struct ContentView: View { // タブの選択項目を保持する @State var selection: Int = 0 var body: some View { TabView(selection: $selection) { Color.red.tag(0).ignoresSafeArea() Color.green.tag(1).ignoresSafeArea() Color.blue.tag(2).ignoresSafeArea() } .ignoresSafeArea() // PageTabViewStyleでページングできる // IndexDisplayMode.neverでインジケーター非表示 .tabViewStyle(.page(indexDisplayMode: .never)) } } PageTabViewStyleを指定すればページングできる

Slide 16

Slide 16 text

Cluster, Inc. 16 横スクロール時のオフセットを取得 TabView(selection: $selection) { Color.red .tag(0) .ignoresSafeArea(edges: .bottom) .overlay { GeometryReader { proxy in Color.clear .onChange(of: proxy.frame(in: .global)) { newValue in // 表示中のタブをスワイプした時のみ処理する guard selection == 0 else { return } // 対象タブのスワイプ量を TabBarの比率に変換して、インジケーターの offsetを計算する let offset = -(newValue.minX - (screenWidth * CGFloat(selection))) / 3 // インジケーター位置を更新 indicatorPosition = offset } } } ... } proxyをグローバル座標に変換してインジケーター位置を更新 ※スクロール量をタブ数で割って比率を出す スクロール量を計測する Viewをオーバーレ イ

Slide 17

Slide 17 text

Cluster, Inc. 17 インタラクティブにインジケーター位置を更新 VStack(spacing: 0) { HStack { Text("Page1") .frame(maxWidth: .infinity, maxHeight: .infinity) ... } .frame(height: 48) .overlay(alignment: .bottomLeading) { Rectangle() .foregroundColor(.black) .frame(width: geometry.size.width / 3, height: 4) .offset(x: indicatorPosition, y: 0) } TabView(selection: $selection) { Color.red .tag(0) .ignoresSafeArea(edges: .bottom) .overlay { ... } ... } .ignoresSafeArea(edges: .bottom) .tabViewStyle(.page(indexDisplayMode: .never)) } インジケーター位置を offsetで指定 インジケーター位置を更新

Slide 18

Slide 18 text

Cluster, Inc. 18 Tips SwiftUIのTabViewでインタラクティブなタブインジケーターを作る SwiftUIでImageをピンチイン・ピンチアウト・ダブルタップでズームさせる SwiftUIでTruncated Text

Slide 19

Slide 19 text

Cluster, Inc. 19 SwiftUIでImageをピンチイン・ピンチアウト・ダブルタップでズームさせる

Slide 20

Slide 20 text

Cluster, Inc. 20 SwiftUIでの基本的なズーム方法 struct ContentView: View { @State private var currentScale: CGFloat = 1.0 @State private var lastMagnificationValue: CGFloat = 1.0 var body: some View { Image("sample") .resizable() .scaledToFit() .scaleEffect(currentScale) .gesture(MagnificationGesture().onChanged({ value in // 前回の拡大率に対する今回の拡大率の割合 let changeRate = value / lastMagnificationValue // 前回からの拡大率の変化分を考慮した現在のスケールを計算 currentScale *= changeRate // 最小・最大スケールの範囲内に収める currentScale = min(max(1.0, currentScale), 10.0) // 拡大率を保持 lastMagnificationValue = value }).onEnded({ value in // ジェスチャー開始時は 1.0から始まるため、ジェスチャー終了時に 1.0に戻す lastMagnificationValue = 1.0 })) } } MagnificationGestureで取得した拡大率を scaleEffectに反映させる ※取得できる拡大率はジェスチャー開始時を 1.0とした 値

Slide 21

Slide 21 text

Cluster, Inc. 21 拡大した画像をスクロールさせる struct ContentView: View { @State private var aspectRatio: CGFloat = 1.0 @State private var currentScale: CGFloat = 1.0 @State private var lastMagnificationValue: CGFloat = 1.0 var body: some View { GeometryReader { proxy in ScrollView([.horizontal, .vertical], showsIndicators: false) { Image("sample") .resizable() .scaledToFit() .frame(width: proxy.size.width) // backgroundでGeometryReaderを使うことで、対象のViewのサイズを取得できる .background(GeometryReader { imageGeometry in Color.clear .onAppear { aspectRatio = imageGeometry.size.width / imageGeometry.size.height } }) .frame(width: proxy.size.width * currentScale, height: proxy.size.width / aspectRatio * currentScale, alignment: .center) .scaleEffect(currentScale) .gesture(magnification) } .background(.black) .ignoresSafeArea() } } } ScrollViewで囲ってframeを拡大率をもとに設 定すれば、拡大した画像をスクロールできる

Slide 22

Slide 22 text

Cluster, Inc. 22 ScrollView + MagnificationGestureの問題点 ● MagnificationGestureの場合、ジェスチャー位置を取 得する方法が調べた限りではなさそうで、 scaleEffect(_:anchor:)のanchorに渡すことができない ため、ジェスチャー位置を中心に拡大することができない ● ジェスチャーとスクロールの相性が良くなく、ジェスチャーし ながらドラッグすると、ズームが中断されることがある

Slide 23

Slide 23 text

Cluster, Inc. 23 struct ContentView: View { var body: some View { ImageViewer(imageName: "sample") .background(.black) .ignoresSafeArea() } } struct ImageViewer: UIViewRepresentable { let imageName: String func makeUIView(context: Context) -> UIImageViewer { let view = UIImageViewer(imageName: imageName) return view } func updateUIView(_ uiView: UIImageViewer, context: Context) {} } 改善策:SwiftUI + UIScrollView UIScrollView,UIImageViewを持つ

Slide 24

Slide 24 text

Cluster, Inc. 24 class UIImageViewer: UIView { private let imageName: String private let scrollView: UIScrollView = UIScrollView() private let imageView: UIImageView = UIImageView() required init(imageName: String) { self.imageName = imageName super.init(frame: .zero) scrollView.delegate = self scrollView.maximumZoomScale = 10.0 scrollView.minimumZoomScale = 1.0 scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false scrollView.contentInsetAdjustmentBehavior = .never imageView.image = UIImage(named: imageName) imageView.contentMode = .scaleAspectFit imageView.isUserInteractionEnabled = true scrollView.addSubview(imageView) addSubview(scrollView) } 改善策:SwiftUI + UIScrollView public override func layoutSubviews() { super.layoutSubviews() scrollView.frame = bounds adjustImageViewSize() updateContentSize() updateContentInset() } } extension UIImageViewerView2: UIScrollViewDelegate { public func viewForZooming(in scrollView: UIScrollView) -> UIView? { return imageView } public func scrollViewDidZoom(_ scrollView: UIScrollView) { updateContentInset() } } UIScrollViewの上にUIImageViewを配置 ズームされるUIViewとして imageViewを返却

Slide 25

Slide 25 text

Cluster, Inc. 25 private func adjustImageViewSize() { guard let size = imageView.image?.size else { return } let rate = min(scrollView.bounds.width / size.width, scrollView.bounds.height / size.height) // scrollView.boundsをもとに拡大率を計算して、 imageViewのサイズを調整する imageView.frame.size = CGSize(width: size.width * rate, height: size.height * rate) } private func updateContentSize() { // scrollView.contentSizeをimageViewのサイズに合わせる scrollView.contentSize = imageView.frame.size } private func updateContentInset() { // imageViewをscrollViewの中心に表示させる let edgeInsets = UIEdgeInsets( top: max((self.frame.height - imageView.frame.height) / 2, 0), left: max((self.frame.width - imageView.frame.width) / 2, 0), bottom: 0, right: 0) scrollView.contentInset = edgeInsets } 改善策:SwiftUI + UIScrollView

Slide 26

Slide 26 text

Cluster, Inc. 26 private var tapGestureRecognizer: UITapGestureRecognizer { let tapGestureRecognizer = UITapGestureRecognizer() tapGestureRecognizer.numberOfTapsRequired = 2 tapGestureRecognizer .tapPublisher .sink { [weak self] recognizer in self?.onDoubleTap(recognizer: recognizer) } .store(in: &cancellables) return tapGestureRecognizer } private func onDoubleTap(recognizer: UITapGestureRecognizer) { let maximumZoomScale = scrollView.maximumZoomScale if maximumZoomScale != scrollView.zoomScale { let tapPoint = recognizer.location(in: imageView) let size = CGSize( width: scrollView.frame.size.width / maximumZoomScale, height: scrollView.frame.size.height / maximumZoomScale) let origin = CGPoint( x: tapPoint.x - size.width / 2, y: tapPoint.y - size.height / 2) scrollView.zoom(to: CGRect(origin: origin, size: size), animated: true) } else { scrollView.zoom(to: scrollView.frame, animated: true) } } UITapGestureRecognizerでダブルタップ ダブルタップ位置を中心に ズームする

Slide 27

Slide 27 text

Cluster, Inc. 27 Tips SwiftUIのTabViewでインタラクティブなタブインジケーターを作る SwiftUIでImageをピンチイン・ピンチアウト・ダブルタップでズームさせる SwiftUIでTruncated Text

Slide 28

Slide 28 text

Cluster, Inc. 28 SwiftUIでTruncated Text

Slide 29

Slide 29 text

Cluster, Inc. 29 SwiftUIでTruncated Text struct TruncatedTextSampleView: View { let text = "吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャー ニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。 " var body: some View { VStack(spacing: 40) { Text(text) .lineLimit(3) TruncatedText(text, lineLimit: 3, ellipsis: .init(text: "More", color: .blue)) } } } テキストが表示できる行数を制限できるが、省略表示 を「...More」のようにカスタマイズできない 任意の省略文字やスタイルを指定できる

Slide 30

Slide 30 text

Cluster, Inc. 30 struct TruncatedText: View { ... var body: some View { Group { Text(truncatedText) + Text(ellipsisPrefixText) + Text(ellipsisText) .font(ellipsisFont) .foregroundColor(ellipsis.color) } .multilineTextAlignment(.leading) .lineLimit(lineLimit) .lineSpacing(lineSpacing) .background( // lineLimitで制限されたテキストをレンダリングして、そのサイズを計測しながら表示できるテキストを更新する Text(text) // 計測用のレイヤーなので非表示にする .hidden() .lineLimit(lineLimit) .background(GeometryReader { visibleTextGeometry in Color.clear .onAppear { // 二分探索でテキストを省略しながら、NSAttributedStringを使って固定幅に対するテキストの高さを取得して、 // その高さがvisibleTextGeometry.size.height以下になったら終了 searchTruncatedText(proxy: visibleTextGeometry) } })) .font(Font(font)) } } SwiftUIでTruncated Text backgroundにサイズ計測用のレイヤーを非表示で用意して、二分探索 でテキストを省略しながら、表示領域に収まるまで省略テキストを更新 する ※このロジックはこちらの記事を元に実装

Slide 31

Slide 31 text

Cluster, Inc. 31 SwiftUIでTruncated Text // 二分探索でテキストを省略しながら、NSAttributedStringを使って固定幅に対するテキストの高さを取得して // その高さがvisibleTextGeometry.size.height以下になったら終了 private func searchTruncatedText(proxy: GeometryProxy) { let size = CGSize(width: proxy.size.width, height: .greatestFiniteMagnitude) let attributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: font] // 二分探索で省略テキストを更新する // 終了条件: mid == low && mid == high var low = 0 var heigh = truncatedText.count var mid = heigh while (heigh - low) > 1 { // 固定幅に対するテキストの高さを取得するためにNSAttributedStringを用いる let attributedText = NSAttributedString( string: truncatedText + ellipsisPrefixText + ellipsisText, attributes: attributes) let boundRect = attributedText.boundingRect( with: size, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil) if boundRect.size.height > proxy.size.height { truncated = true heigh = mid mid = (heigh + low) / 2 } else { if mid == text.count { break } else { low = mid mid = (low + heigh) / 2 } } truncatedText = String(text.prefix(mid)) } } NSAttributedStringで固定幅に対するテキストの高さを 取得して、表示領域の高さと比較しながら探索

Slide 32

Slide 32 text

Cluster, Inc. 32 Tips SwiftUIのTipsを紹介したが、いずれも力技が否めないので、 SwiftUIが今後さらに進化して、複雑なUIも宣言的で簡単に組め ることに期待!

Slide 33

Slide 33 text

No content