我想创建一个简单的自定义分段控制组件,其动画类似于本机组件。我见过很多人为此使用
matchedGeometryEffect
,但是当我实现该组件时,我注意到动画期间的行为:当我的背景矩形向右滑动时,先前选择的选项卡移动到该视图后面,这会产生令人不快的撕裂运动而不是平滑的动画。我怀疑这个问题与 Z-Stack 视图重新排序有关,但我不知道如何解决它,请帮助我。
这是我正在使用的代码
import SwiftUI
struct TabSegmentedControlView: View {
@State private var currentTab = 0
var body: some View {
VStack {
TabBarView(currentTab: $currentTab)
}
}
}
struct TabBarView: View {
@Binding var currentTab: Int
@Namespace var namespace
var tabBarOptions: [String] = ["shield", "house", "hands.clap"]
var body: some View {
HStack(spacing: 0) {
ForEach(
Array(zip(self.tabBarOptions.indices,
self.tabBarOptions)),
id: \.0,
content: { id, name in
TabBarTabView(
currentTab: $currentTab,
namespace: namespace.self,
icon: name,
title: name,
tab: id
)
}
)
}
.padding(.all, 2)
.background(.gray.opacity(0.2))
.cornerRadius(16)
.padding(.horizontal, 16)
}
}
struct TabBarTabView: View {
@Binding var currentTab: Int
let namespace: Namespace.ID
let icon: String
let title: String
let tab: Int
var body: some View {
Button(action: {
self.currentTab = tab
}) {
ZStack {
if tab == currentTab {
Color.white
.frame(height: 60)
.frame(maxWidth: .infinity)
.cornerRadius(14)
.shadow(color: .black.opacity(0.04), radius: 0.5, x: 0, y: 3)
.shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 3)
.transition(.offset())
.matchedGeometryEffect(
id: "slidingRect",
in: namespace,
properties: .frame
)
}
VStack(spacing: 4) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
Text(title)
}
.padding()
.frame(maxWidth: .infinity)
.font(Font.body.weight(.medium))
.foregroundColor(tab == currentTab ? .black : .gray)
.transition(.opacity)
.cornerRadius(14)
.frame(height: 60)
}
.animation(.easeInOut, value: self.currentTab)
}
}
}
#Preview {
TabSegmentedControlView()
}
预览:点击此处
您目前的方式是,每个按钮都有自己的白色背景。您正在使用
.matchedGeometryEffect
来动画化从一个按钮背景到另一个按钮背景的变化,但这并不是无缝工作的。
我建议,实现移动背景的更好方法是只有一个在按钮之间移动的形状。主要变化如下:
删除各个按钮的背景,并将形状添加到
HStack
的背景中。
按钮应用作
.matchedGeometryEffect
的源,因此每个按钮应具有唯一的 id。
背景应有
isSource: false
并使用所选选项卡的 id。这样,背景就会与所选按钮相匹配。
将
.animation
修改器从 TabBarTabView
移至 HStack
。
其他建议:
无需对背景形状应用任何尺寸,因为尺寸来自
.matchedGeometryEffect
。
也不需要任何
.transition
修饰符,因为没有视图出现或消失。
修饰符
.foregroundColor
已弃用,请使用 .foregroundStyle
代替。
修饰符
.cornerRadius
也已弃用。您可以将 .clipShape
与 RoundedRectangle
一起使用,或者只是将 RoundedRectangle
放在背景中并用所需的颜色填充它。
修饰符
.scaledToFit()
的作用与 .aspectRatio(contentMode: .fit)
相同,但可能更简单。
无需使用
self
限定变量。如果颜色和字体已经是使用上下文中的预期类型,则无需使用 Color
和 Font
来限定颜色和字体。
ForEach
的数组可以通过将.enumerated()
应用到源数组来构建,而不是使用索引压缩内容。
尽可能使用尾随闭包。
这是完全更新的示例:
struct TabBarView: View {
@Binding var currentTab: Int
@Namespace var namespace
var tabBarOptions: [String] = ["shield", "house", "hands.clap"]
var body: some View {
HStack(spacing: 0) {
ForEach(Array(tabBarOptions.enumerated()), id: \.offset) { id, name in
TabBarTabView(
currentTab: $currentTab,
namespace: namespace,
icon: name,
title: name,
tab: id
)
}
}
.background {
RoundedRectangle(cornerRadius: 14)
.fill(.white)
.shadow(color: .black.opacity(0.04), radius: 0.5, x: 0, y: 3)
.shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 3)
.matchedGeometryEffect(
id: currentTab,
in: namespace,
isSource: false
)
}
.padding(2)
.background {
RoundedRectangle(cornerRadius: 16)
.fill(.gray.opacity(0.2))
}
.padding(.horizontal, 16)
.animation(.easeInOut, value: currentTab)
}
}
struct TabBarTabView: View {
@Binding var currentTab: Int
let namespace: Namespace.ID
let icon: String
let title: String
let tab: Int
var body: some View {
Button {
currentTab = tab
} label: {
VStack(spacing: 4) {
Image(systemName: icon)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(title)
}
.padding()
.frame(maxWidth: .infinity)
.font(.body.weight(.medium))
.foregroundStyle(tab == currentTab ? .black : .gray)
.frame(height: 60)
}
.matchedGeometryEffect(
id: tab,
in: namespace,
isSource: true
)
}
}