Cái animation nhảy phựt, và câu `if` gây ra nó
Một cái thẻ trong một màn hình của tôi nở ra khi bấm vào. Lượt nở ra chạy animation rất đẹp. Lượt thu lại thì bụp một cái, không animation gì cả, lần nào cũng vậy.
Vẫn withAnimation đó, vẫn đường spring đó, vẫn view đó. Một chiều chạy được còn chiều kia thì không,
và kiểu bất đối xứng đó nghĩa là vấn đề không nằm ở animation.
Đoạn code
struct CardView: View {
@State private var isExpanded = false
var body: some View {
if isExpanded {
ExpandedCard(content: content)
.onTapGesture { withAnimation(.spring) { isExpanded = false } }
} else {
CollapsedCard(content: content)
.onTapGesture { withAnimation(.spring) { isExpanded = true } }
}
}
}
Đoạn này trông hoàn toàn hợp lý. Nó cũng chính là cái lỗi, và tôi đã mất một khoảng thời gian đáng xấu hổ mới nhìn ra, vì tôi đọc nó là “cái thẻ đổi diện mạo” trong khi SwiftUI đọc nó thành một thứ hoàn toàn khác.
SwiftUI nhìn thấy gì
@ViewBuilder biên dịch câu if/else đó thành _ConditionalContent<ExpandedCard, CollapsedCard>.
Hai nhánh ấy là hai vị trí khác nhau trong cây view, nghĩa là chúng có danh tính khác nhau, nghĩa
là chúng là hai view khác nhau.
Nên chẳng có gì chạy animation từ thu lại sang nở ra cả. Thứ thật sự xảy ra là:
CollapsedCardbị gỡ khỏi câyExpandedCardđược chèn vào cây
Đó là một chuyển cảnh, không phải một animation thuộc tính. Không có phép nội suy nào giữa hai khung hình vì chẳng có gì chung để mà nội suy — SwiftUI không có lý do nào để tin rằng chiều cao của view này liên quan đến chiều cao của một view hoàn toàn khác.
Lý do một chiều trông như có animation chỉ là tình cờ. ExpandedCard chứa một VStack mà nội dung
bên trong tự chạy animation, nên cú chèn vào tình cờ trông mượt. Lượt thu lại không có tai nạn may
mắn nào như vậy.
Cách chữa
Một view duy nhất, với các thuộc tính thay đổi:
struct CardView: View {
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading) {
Text(content.title)
.lineLimit(isExpanded ? nil : 1)
if isExpanded {
Text(content.body)
.transition(.opacity)
}
}
.frame(maxHeight: isExpanded ? .infinity : 60)
.onTapGesture {
withAnimation(.spring) { isExpanded.toggle() }
}
}
}
Giờ có một VStack với một danh tính xuyên suốt cả hai trạng thái. maxHeight của nó đổi từ 60 sang
.infinity, và maxHeight là một CGFloat, thứ tuân thủ Animatable, nên SwiftUI nội suy nó. Cả
hai chiều đều chạy animation, vì cả hai chiều đều là cùng một view đang thay đổi.
Câu if bên trong vẫn là một câu điều kiện và vẫn tạo ra rồi hủy đi một view — nhưng cái đó có
.transition(.opacity), nên lối vào và lối ra của nó được mô tả chứ không phải tức thì.
Mẹo
Sự phân biệt đáng khắc vào đầu: một view giữ nguyên danh tính thì chạy animation; một view nhận danh tính mới thì chuyển cảnh. Đó là hai cơ chế khác nhau và chúng cần hai công cụ khác nhau. Nếu bạn đã viết một đường spring mà lại thấy một cú cắt phựt, gần như chắc chắn bạn đang ở trường hợp thứ hai trong khi vẫn nghĩ mình ở trường hợp thứ nhất.
Cách nhận ra nó
Hai triệu chứng chỉ về phía một vấn đề danh tính chứ không phải vấn đề animation:
@State bị đặt lại. Nếu một ô nhập chữ bên trong view quên mất thứ vừa gõ, hoặc vị trí cuộn nhảy
lên đầu, thì view đang bị hủy rồi dựng lại. Ở đây, animation và việc mất trạng thái có cùng một gốc.
Animation là tức thì chứ không phải sai. Một đường spring chỉnh tệ thì trông nảy quá hoặc ì quá. Một thay đổi danh tính thì trông như chẳng có gì xảy ra cả — khung hình này là A, khung hình sau là B. Cái chất “không có animation nào hết” ấy chính là dấu hiệu.
Phép chẩn đoán chốt hạ:
var body: some View {
let _ = Self._printChanges()
…
}
Nếu console in ra @identity thay vì một tên thuộc tính thì view đang bị thay thế. Việc đó tốn mười
giây và dẹp sạch mọi phỏng đoán.
Hình dạng chung
Giờ tôi coi mọi câu if/else trong một body mà cả hai nhánh vẽ ra cùng một thứ về mặt khái
niệm là một mùi lạ. Vài ví dụ tôi tìm thấy trong chính codebase đó sau khi sửa cái này:
// hai nhánh, cùng một trường, hai cấu hình khác nhau
if isEditing {
TextField("Tên", text: $name)
} else {
TextField("Tên", text: $name).disabled(true)
}
Cái đó đóng bàn phím mỗi lần isEditing lật, vì ô nhập chữ bị hủy. Cách chữa là
TextField("Tên", text: $name).disabled(!isEditing) — một view, một danh tính.
// hai nhánh, cùng một danh sách, hai bộ dữ liệu khác nhau
if showArchived {
List(archivedItems) { ItemRow(item: $0) }
} else {
List(activeItems) { ItemRow(item: $0) }
}
Cái đó làm mất vị trí cuộn và không chạy animation được cho các thay đổi dòng. Cách chữa là một
List duy nhất trên một mảng được tính ra.
Quy tắc tôi rút ra: dùng câu điều kiện khi hai nhánh thật sự là hai nội dung khác nhau. Dùng một thuộc tính thay đổi khi chúng là cùng một nội dung ở hai trạng thái khác nhau. Gần như mọi lỗi animation tôi từng gặp trong SwiftUI đều là câu đó, áp dụng sai.