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をご確認ください。
おわり
お読みいただきありがとうございます!
もしなにかのお役に立つことがございましたら、
♡を頂けますとすごく励みになります😊
この記事が気に入ったらサポートをしてみませんか?