Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Cluster_Extended Tokyo_WWDC 2023

Cluster_Extended Tokyo_WWDC 2023

Cluster, Inc.

June 07, 2023
Tweet

More Decks by Cluster, Inc.

Other Decks in Technology

Transcript

  1. Cluster, Inc. 3 自己紹介 Ahi To / TAAT クラスター株式会社 /

    ソフトウェアエンジニア メタバースにチャレンジしてみたくて、2023年1 月からクラスターにジョイン! taatn0te @TAAT626 TAATHub
  2. Cluster, Inc. 5 clusterとは? マルチプラットフォーム対応 iOS Android macOS Windows Desktop

    / VR Meta Quest 2 実はこれらのプラットフォーム向けのクライアントアプリケーションを毎週リ リースしている! PC,モバイル,VRに対応したマルチプラットフォームアプリのリリースフロー
  3. Cluster, Inc. 6 アバターワーク チームのバーチャル仕事部屋 cluster内でMTGや雑談をしたり、その場で ワールドを 改 築 したり、リモートワークでもコ

    ミュニケーションが取りやすい! クラスターに入社して 1ヶ月経ってみて アバターでWeb会議 Web会議もアバターで参加できる!アバター ワーク感があって良い!
  4. Cluster, Inc. 9 iOSエンジニアの活躍領域 outroom inroom cluster Unity-iPhone UnityFramework SwiftPackage

    xcframework Unity as a LibraryをSwiftPM経由で導入してiOSビルド環境を改善した話 Unity as a Library Unityで構築したアプリケーションをネイティブアプリの ライブラリとして扱える
  5. Cluster, Inc. 10 iOSエンジニアの活躍領域 iOSエンジニアは、バーチャル空間外の非同期的 な体験の機能開発を担当 outroom SwiftUIで苦戦したり、UIKitを併用する必要のあった部 分について紹介 clusterではiOS

    15.0以上をサポートしており、新規画面 では積極的にSwiftUIを使用しているが、まだまだUIKit の部分が多く、SwiftUIだけではできない部分もあるの で、SwiftUIとUIKitを併用している
  6. 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を指定すればページングできる
  7. 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をオーバーレ イ
  8. 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で指定 インジケーター位置を更新
  9. 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とした 値
  10. 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を拡大率をもとに設 定すれば、拡大した画像をスクロールできる
  11. Cluster, Inc. 22 ScrollView + MagnificationGestureの問題点 • MagnificationGestureの場合、ジェスチャー位置を取 得する方法が調べた限りではなさそうで、 scaleEffect(_:anchor:)のanchorに渡すことができない

    ため、ジェスチャー位置を中心に拡大することができない • ジェスチャーとスクロールの相性が良くなく、ジェスチャーし ながらドラッグすると、ズームが中断されることがある
  12. 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を持つ
  13. 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を返却
  14. 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
  15. 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でダブルタップ ダブルタップ位置を中心に ズームする
  16. 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」のようにカスタマイズできない 任意の省略文字やスタイルを指定できる
  17. 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にサイズ計測用のレイヤーを非表示で用意して、二分探索 でテキストを省略しながら、表示領域に収まるまで省略テキストを更新 する ※このロジックはこちらの記事を元に実装
  18. 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で固定幅に対するテキストの高さを 取得して、表示領域の高さと比較しながら探索