I'm struggling to understand why .matchedGeometryEffect()
is behaving this way:
I'm trying to animate a tab's height to different heights, based on its content. To do so I'm using .matchedGeometryEffect()
on the tab background as follow:
struct ContentView: View {
// MARK: - Properties
@State private var selectedTab: Tab = .payment
@Namespace var namespace
// MARK: - Body
var body: some View {
ZStack(alignment: .bottom) {
// Background color
Color.gray6
.ignoresSafeArea()
// Tab
switch selectedTab {
case .dashboard:
DashboardTabView(namespace: namespace)
case .transactions:
TransactionsTabView(namespace: namespace)
case .payment:
PaymentTabView(namespace: namespace)
case .services:
ServicesTabView(namespace: namespace)
case .settings:
SettingsTabView(namespace: namespace)
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
// Tab bar
TabBar(selectedTab: $selectedTab)
}
}
}
struct DashboardTabView: View {
// MARK: - Properties
let namespace: Namespace.ID
// MARK: - Body
var body: some View {
TabViewContainer(namespace: namespace) {
Text("Dashboard Tab")
.foregroundStyle(.black)
}
}
}
struct PaymentTabView: View {
// MARK: - Properties
let namespace: Namespace.ID
// MARK: - Body
var body: some View {
TabViewContainer(namespace: namespace) {
HStack(spacing: 16) {
CTAButton(title: "Receive", icon: "arrow.down.square") { }
CTAButton(title: "Pay", icon: "dollarsign.arrow.circlepath", color: Color(hex: "#F1D302")) { }
}
.frame(height: 52)
.padding(.horizontal)
}
}
}
struct TabViewContainer<Content: View>: View {
// MARK: - Init
init(
cornerRadii: RectangleCornerRadii = .init(topLeading: 21, bottomLeading: 0, bottomTrailing: 0, topTrailing: 21),
namespace: Namespace.ID,
@ViewBuilder content: @escaping () -> Content
) {
self.cornerRadii = cornerRadii
self.namespace = namespace
self.content = content
}
// MARK: - Properties
let namespace: Namespace.ID
@State private var childHeight: CGFloat = 0
@ViewBuilder var content: () -> Content
private let cornerRadii: RectangleCornerRadii
// MARK: - Body
var body: some View {
ZStack(alignment: .bottom) {
// Background color
Color.gray0
.matchedGeometryEffect(id: "tab_background", in: namespace)
.frame(height: childHeight)
.clipShape(UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous))
// Content
content()
.padding(.vertical)
.overlay {
GeometryReader { proxy in
Color.clear
.task(id: proxy.size.height) {
childHeight = max(proxy.size.height, 0)
}
}
}
}
}
}
I can see the animation on the screen but it looks like instead of animating from the origin view to the destination view directly, it reset the height to 0 first, causing the animation to break.
Would anybody understand why it's behaving this way by any chance? Am I missing something in the implementation?
Here's what it looks like right now. I slowed down the animation on Xcode simulator so you can see it more clearly:
I'm trying to display some tabs based on the selectedTab
value in the ContentView
. These tabs (DashboardTabView
, PaymentTabView
, etc) all leverage the same TabViewContainer
that contains a Color.gray0
view that uses .matchedGeometryEffect(id: "tab_background", in: namespace)
.
My expectation is that these tab will transition height seamlessly from one tab to another, and in fact when I hard code the values for the height the animation is working propertly.
The problem arises when I use GeometryReader
to retrieve the height of the content()
child view. Then the animation breaks.
UPDATE: 01/10
I tried to pass a selectedTab
environment key down the subviews to set isSource
for the selected tab but it stopped animating all together.
Here's my new implementation:
struct SelectedTabEnvironmentKey: EnvironmentKey {
static let defaultValue: Binding<Tab> = .constant(.payment)
}
extension EnvironmentValues {
var selectedTab: Binding<Tab> {
get { self[SelectedTabEnvironmentKey.self] }
set { self[SelectedTabEnvironmentKey.self] = newValue }
}
}
struct ContentView: View {
// MARK: - Properties
@Namespace var namespace
@State private var selectedTab: Tab = .payment
// MARK: - Body
var body: some View {
ZStack(alignment: .bottom) {
// Tab
switch selectedTab {
case .dashboard:
DashboardPage(namespace: namespace)
case .transactions:
TransactionsPage(namespace: namespace)
case .payment:
PaymentPage(namespace: namespace)
case .services:
ServicesPage(namespace: namespace)
case .settings:
SettingsPage(namespace: namespace)
}
}
.environment(\.selectedTab, $selectedTab)
.safeAreaInset(edge: .bottom, spacing: 0) {
// Tab bar
TabBar(selectedTab: $selectedTab)
}
}
}
struct DashboardPage: View {
// MARK: - Properties
let namespace: Namespace.ID
// MARK: - Body
var body: some View {
Page(namespace: namespace) {
// Content
Text("Dashboard page")
.foregroundStyle(.white)
.frame(maxHeight: .infinity)
} tabView: {
DashboardTabView(namespace: namespace)
}
}
}
struct Page<Content: View, TabView: TabViewProtocol>: View {
// MARK: - Init
init(
namespace: Namespace.ID,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder tabView: @escaping () -> TabView
) {
self.namespace = namespace
self.content = content
self.tabView = tabView
}
// MARK: - Properties
let namespace: Namespace.ID
@ViewBuilder var content: () -> Content
@ViewBuilder var tabView: () -> TabView
@State private var tabViewHeight: CGFloat = 0
// MARK: - Body
var body: some View {
ZStack(alignment: .bottom) {
// Background color
Color.gray6
.ignoresSafeArea()
// Content
VStack {
content()
Color.clear
.frame(height: tabViewHeight)
}
// Tab
tabView()
.overlay {
GeometryReader { proxy in
Color.clear
.task(id: proxy.size.height) {
guard proxy.size.height > 0 else { return }
tabViewHeight = max(proxy.size.height, 0)
}
}
}
}
}
}
struct DashboardTabView: TabViewProtocol {
// MARK: - Properties
let namespace: Namespace.ID
let transactions: [Transaction] = Transaction.section1
@Environment(\.selectedTab) @Binding var selectedTab: Tab
// MARK: - Body
var body: some View {
// Tab binding
let isActive = Binding(
get: { self.selectedTab == .dashboard },
set: { _ in }
)
// View body
return TabViewContainer(isActive: isActive, namespace: namespace) {
VStack {
// Header
HStack {
Text("Transactions")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Color.gray6)
Spacer()
Text("See all")
.underline()
.foregroundStyle(Color.gray3)
.font(.system(size: 14, weight: .medium))
}
.padding(.vertical)
// Transactions
TransactionListSectionView(day: "Fri", date: "12", transactions: transactions)
}
.padding()
}
}
}
struct TabViewContainer<Content: View>: View {
// MARK: - Init
init(
cornerRadii: RectangleCornerRadii = .init(topLeading: 21, bottomLeading: 0, bottomTrailing: 0, topTrailing: 21),
isActive: Binding<Bool>,
namespace: Namespace.ID,
@ViewBuilder content: @escaping () -> Content
) {
self._isActive = isActive
self.cornerRadii = cornerRadii
self.namespace = namespace
self.content = content
}
// MARK: - Properties
let namespace: Namespace.ID
@Binding var isActive: Bool
@State private var childHeight: CGFloat = 0
@ViewBuilder var content: () -> Content
private let cornerRadii: RectangleCornerRadii
// MARK: - Body
var body: some View {
ZStack(alignment: .bottom) {
// Background color
Color.gray0
.matchedGeometryEffect(id: "tab_background", in: namespace, isSource: isActive)
.frame(height: childHeight)
.clipShape(UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous))
// Content
content()
.overlay {
GeometryReader { proxy in
Color.clear
.task(id: proxy.size.height) {
guard proxy.size.height > 0 else { return }
childHeight = max(proxy.size.height, 0)
}
}
}
}
}
}
In the example above, I'm just showing the DashboardTabView
but all of the other tab views also have the same implementation and use the TabViewContainer
component