Trang chủ

Chuyển cảnh và matched geometry

View đi vào và đi ra, và view trông như di chuyển giữa hai chỗ

Một chuyển cảnh mô tả cách một view đi vào và rời khỏi cây, khác với cách các thuộc tính của nó thay đổi trong lúc nó ở lại.

if showDetail {
    DetailPanel()
        .transition(.move(edge: .bottom).combined(with: .opacity))
}

Hai quy tắc quyết định chuyện này có chạy hay không, và cả hai đều hay bẫy người ta.

Một chuyển cảnh cần một animation. Chuyển cảnh nói lối vào trông như thế nào; animation nói rằng thay đổi đó nên được nội suy. Không có withAnimation bọc quanh thay đổi trạng thái, hay một .animation(_:value:) ở gần đó, view sẽ hiện ra tức thì và chuyển cảnh không bao giờ chạy.

Chuyển cảnh đặt trên view xuất hiện, bên trong cái if — không phải trên container. Đặt nó lên cái VStack ở ngoài thì nó mô tả sự xuất hiện của chính cái stack, thứ chẳng bao giờ xảy ra.

Các chuyển cảnh có sẵn ghép được với nhau

.opacity, .scale, .slide, .move(edge:), .push(from:), .offset và .blurReplace ghép được bằng .combined(with:), còn lối vào và lối ra bất đối xứng là một trường hợp riêng:

.transition(.asymmetric(
    insertion: .move(edge: .trailing).combined(with: .opacity),
    removal: .opacity
))

Cái mẫu đó — trượt vào, mờ ra — đáng biết vì nó là thứ mà phần lớn giao diện mang cảm giác điều hướng thật sự cần. Một view trượt ra đúng lối nó đã trượt vào thường trông như một sai lầm đang được hoàn tác.

Cảnh báo

Một chuyển cảnh đặt trên view bên trong List hay LazyVStack thường chẳng làm gì. Các container lười tạo ra và hủy đi các dòng vì lý do của riêng chúng, và cú chèn bạn định làm thì không phân biệt được với một dòng vừa được hiện thực hóa. Hãy chạy animation cho thay đổi dữ liệu bằng withAnimation và để danh sách tự lo animation dòng của nó.

Tự viết một chuyển cảnh

Một chuyển cảnh là một cặp modifier áp vào hai đầu, và ViewModifier cộng với AnyTransition.modifier diễn đạt thẳng điều đó:

struct Tilt: ViewModifier {
    let angle: Double

    func body(content: Content) -> some View {
        content
            .rotationEffect(.degrees(angle))
            .opacity(angle == 0 ? 1 : 0)
    }
}

extension AnyTransition {
    static var tilt: AnyTransition {
        .modifier(active: Tilt(angle: 20), identity: Tilt(angle: 0))
    }
}

active là trạng thái view ở khi vắng mặt — trước khi nó vào, và sau khi nó ra. identity là trạng thái khi nó hiện diện đầy đủ. SwiftUI nội suy giữa hai trạng thái ấy.

matchedGeometryEffect

Đôi khi một view không nên xuất hiện rồi biến mất — nó nên trông như cùng một phần tử đang di chuyển từ chỗ này sang chỗ khác. Chẳng hạn một ảnh thu nhỏ trong lưới nở ra thành ảnh toàn màn hình. Về mặt cấu trúc thì đó là hai view khác nhau ở hai chỗ khác nhau trong cây, nên một chuyển cảnh sẽ mờ cái này đi và hiện cái kia lên.

matchedGeometryEffect bắc cầu qua chỗ đó:

struct Gallery: View {
    @Namespace private var namespace
    @State private var selected: Photo?

    var body: some View {
        ZStack {
            if let photo = selected {
                FullImage(photo: photo)
                    .matchedGeometryEffect(id: photo.id, in: namespace)
                    .onTapGesture { withAnimation(.spring) { selected = nil } }
            } else {
                LazyVGrid(columns: columns) {
                    ForEach(photos) { photo in
                        Thumbnail(photo: photo)
                            .matchedGeometryEffect(id: photo.id, in: namespace)
                            .onTapGesture { withAnimation(.spring) { selected = photo } }
                    }
                }
            }
        }
    }
}

Cả hai view khai báo cùng một id trong cùng một @Namespace. Khi một cái rời đi và cái kia đi vào, SwiftUI nội suy phần hình học — vị trí, kích thước — giữa chúng, nên ảnh thu nhỏ trông như bay vào đúng chỗ.

Các quy tắc làm nó chạy được:

  • id phải khớp, và namespace phải dùng chung. Đặt @Namespace ở tổ tiên chung, truyền xuống nếu các view nằm ở những kiểu riêng biệt.
  • Đúng hai view được giữ một id cùng lúc, và bình thường chỉ nên có một cái hiện diện tại một thời điểm. Ba view cùng id, hoặc hai cái cùng nhìn thấy được, sẽ sinh ra một cảnh báo lúc chạy và phần hình học nhảy loạn.
  • Nó phải nằm trong withAnimation. Giống chuyển cảnh, không có animation thì nó chẳng nội suy gì.
  • Nó khớp hình học, không khớp nội dung. Ảnh của bản thu nhỏ và ảnh đầy đủ vẫn là hai view khác nhau vẽ những thứ khác nhau; chỉ có cái khung được nội suy. Nếu nội dung khác nhau rõ rệt, hãy thêm .transition(.opacity) để cú mờ chồng che đi.

Mẹo

Khi một animation matched geometry trông sai, nguyên nhân gần như luôn là cả hai view cùng tồn tại một lúc. Hãy đặt chúng vào hai nhánh thật sự loại trừ nhau của một if/else thay vì giấu một cái bằng .opacity(0) — một view bị giấu thì vẫn hiện diện, vẫn giữ cái id, và vẫn tranh phần hình học.

Phase và keyframe animator

Với những animation nhiều bước không do trạng thái điều khiển, hai công cụ chuyên dụng ăn đứt việc nối các lời gọi withAnimation kèm độ trễ:

Image(systemName: "bell")
    .phaseAnimator([0, -20, 20, 0], trigger: unreadCount) { view, angle in
        view.rotationEffect(.degrees(angle))
    }

phaseAnimator bước qua từng pha rời rạc; keyframeAnimator điều khiển nhiều thuộc tính trên những dòng thời gian độc lập. Cả hai là câu trả lời đúng cho một cú rung, một nhịp đập hay một cú nảy ăn mừng — những hiệu ứng là một chuỗi cố định chứ không phải một hành trình giữa hai trạng thái.