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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  11. 11
    SwiftUIのTips

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  15. 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を指定すればページングできる

    View Slide

  16. 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をオーバーレ

    View Slide

  17. 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で指定
    インジケーター位置を更新

    View Slide

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

    View Slide

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

    View Slide

  20. 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とした

    View Slide

  21. 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を拡大率をもとに設
    定すれば、拡大した画像をスクロールできる

    View Slide

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

    View Slide

  23. 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を持つ

    View Slide

  24. 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を返却

    View Slide

  25. 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

    View Slide

  26. 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でダブルタップ
    ダブルタップ位置を中心に
    ズームする

    View Slide

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

    View Slide

  28. Cluster, Inc. 28
    SwiftUIでTruncated Text

    View Slide

  29. 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」のようにカスタマイズできない
    任意の省略文字やスタイルを指定できる

    View Slide

  30. 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にサイズ計測用のレイヤーを非表示で用意して、二分探索
    でテキストを省略しながら、表示領域に収まるまで省略テキストを更新
    する
    ※このロジックはこちらの記事を元に実装

    View Slide

  31. 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で固定幅に対するテキストの高さを
    取得して、表示領域の高さと比較しながら探索

    View Slide

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

    View Slide

  33. View Slide