見出し画像

SwiftUIで吸い込みアニメーション(ジニーエフェクト)

概要

今回はSwiftUIで「吸い込み/吐き出し」の様なアニメーションを実装する方法をご紹介します。

Macをお使いの方にはお馴染みの「ジニーエフェクト」というやつです!

スピークバディではこんな感じで使用しています。

コード紹介

まず、UIKitでジニーエフェクトを再現できるBCGenieEffectという、とても凄いパッケージがあるのでこちらを導入してください。

これをSwiftUIで使える形にextensionを定義していきます。

実装

extension View {
  func genieTransition(isHidden: Bool, duration: TimeInterval, delay: TimeInterval = 0, rect: CGRect, edge: BCRectEdge, completion: (() -> Void)? = nil) -> some View {
    modifier(EDGenieEffectModifier(isHidden: isHidden, duration: duration, delay: delay, rect: rect, edge: edge, animation: nil, value: "", completion: completion))
  }
  
  // 別途SwiftUIで実装するアニメーションと同時併用したい場合は"animation"プロパティがあるこちらを使用します。
  func genieTransition<Value: Equatable>(isHidden: Bool, duration: TimeInterval, delay: TimeInterval = 0, rect: CGRect, edge: BCRectEdge, animation: Animation, value: Value, completion: (() -> Void)? = nil) -> some View {
    modifier(EDGenieEffectModifier(isHidden: isHidden, duration: duration, delay: delay, rect: rect, edge: edge, animation: animation, value: value, completion: completion))
  }
}

private struct EDGenieEffectModifier<Value: Equatable>: ViewModifier {
  @State private var contentSize = CGSize.infinity
  let isHidden: Bool
  let duration: TimeInterval
  let delay: TimeInterval
  let rect: CGRect
  let edge: BCRectEdge
  let animation: Animation?
  let value: Value
  let completion: (() -> Void)?
  
  func body(content: Content) -> some View {
    if let animation {
      EDGenieEffectWrapper(isHidden: isHidden, duration: duration, delay: delay, rect: rect, edge: edge, completion: completion) {
        content.geometryReader(in: .local) { contentSize = $1.size }
      }
      .frame(width: contentSize.width, height: contentSize.height)
      .animation(animation, value: value)
    } else {
      EDGenieEffectWrapper(isHidden: isHidden, duration: duration, delay: delay, rect: rect, edge: edge, completion: completion) {
        content.geometryReader(in: .local) { contentSize = $1.size }
      }
      .frame(width: contentSize.width, height: contentSize.height)
    }
  }
}

private struct EDGenieEffectWrapper<Content: View>: UIViewControllerRepresentable {
  let isHidden: Bool
  let duration: TimeInterval
  let delay: TimeInterval
  let rect: CGRect
  let edge: BCRectEdge
  let completion: (() -> Void)?
  @ViewBuilder let content: Content
  
  func makeUIViewController(context: Context) -> UIViewController {
    let vc = UIViewController()
    let hc = UIHostingController(rootView: content)
    
    vc.addChild(hc)
    vc.view.addSubview(hc.view)
    hc.didMove(toParent: vc)
    
    hc.view.backgroundColor = .clear
    hc.view.translatesAutoresizingMaskIntoConstraints = false

    NSLayoutConstraint.activate([
      hc.view.topAnchor.constraint(equalTo: vc.view.topAnchor),
      hc.view.leftAnchor.constraint(equalTo: vc.view.leftAnchor),
      hc.view.rightAnchor.constraint(equalTo: vc.view.rightAnchor),
      hc.view.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor)
    ])

    context.coordinator.oldValue = isHidden
    
    if isHidden {
      DispatchQueue.main.async {
        hc.view.genieInTransition(withDuration: .leastNormalMagnitude, destinationRect: rect, destinationEdge: edge) {}
      }
    }
    
    return vc
  }
  
  func updateUIViewController(_ vc: UIViewController, context: Context) {
    guard isHidden != context.coordinator.oldValue, let view = vc.view.subviews.first else { return }
    context.coordinator.oldValue = isHidden
    
    if isHidden {
      DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
        view.genieInTransition(withDuration: duration, destinationRect: rect, destinationEdge: edge) { completion?() }
      }
    } else {
      DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
        view.genieOutTransition(withDuration: duration, start: rect, start: edge) { completion?() }
      }
    }
  }
  
  func makeCoordinator() -> Coordinator {
    .init()
  }
  
  class Coordinator {
    var oldValue: Bool?
  }
}

※別の記事で紹介したgeometryReaderメソッドを使用しています

もし何故かレイアウトが崩れてしまう場合は…

UIHostingController側のナビゲーションバーの扱いが問題かもしれません。
以下のカスタムHostingControllerを定義して使用してください。

import SwiftUI

class SBHostingController<Content: View>: UIHostingController<AnyView> {
  private let isNavigationBarHidden: Bool
  
  init(rootView: Content, isNavigationBarHidden: Bool = true) {
    self.isNavigationBarHidden = isNavigationBarHidden
    super.init(rootView: AnyView(rootView.navigationBarHidden(isNavigationBarHidden)))
  }
  
  @MainActor required dynamic init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    if navigationController?.isNavigationBarHidden != isNavigationBarHidden {
      navigationController?.setNavigationBarHidden(isNavigationBarHidden, animated: false)
    }
  }
}

使用例

struct SampleView: View {
  @State private var isHidden = false
  @State private var redRect = CGRect()
  @State private var blueRect = CGRect()
  
  private var rect: CGRect {
    .init(x: blueRect.minX - redRect.minX, y: blueRect.minY - redRect.minY, width: 50, height: 50)
  }
  
  var body: some View {
    VStack(spacing: 20) {
      Color.red
        .frame(width: 200, height: 200)
        .genieTransition(isHidden: isHidden, duration: 0.75, rect: rect, edge: .top)
        .geometryReader(in: .global) { redRect = $1.frame(in: $0) }
      Color.blue
        .frame(width: 50, height: 50)
        .geometryReader(in: .global) { blueRect = $1.frame(in: $0) }
    }
    .onAppear {
      DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        isHidden = true
      }
    }
  }
}

#Preview {
  SampleView()
}
  • isHiddenプロパティでしまったり出したりを制御します

  • rectプロパティでは移動先を指定します

  • edgeプロパティで上下左右どこに吸い付けるかを指定します

このあたりの詳しい仕様はBCGenieEffectのREADMEをご確認ください。

おわり

お読みいただきありがとうございます!
もしなにかのお役に立つことがございましたら、
♡を頂けますとすごく励みになります😊

この記事が気に入ったらサポートをしてみませんか?