阅读视图

发现新文章,点击刷新页面。

SwiftUI 手势冲突:修复 Navigation 返回手势

欢迎大家给我点个 star!Github: RickeyBoy

问题背景

在开发过程中遇到一个体验上的冲突问题,当用户在使用可横向翻页的视图(如 TabView 的 page 样式)时,第一页无法从屏幕边缘滑动返回上一页。返回手势总是被 TabView 的手势拦截,具体表现可以看下面这个 gif 图:

failure.gif

原因分析

为什么会这样?

  1. 手势竞争问题:
- Navigation Controller:提供边缘滑动返回手势
- TabView:拥有用于页面切换的横向拖动手势

2. 优先级冲突:

- 两个手势都识别横向滑动
- TabView 的手势先捕获触摸
- Navigation 手势永远没有机会响应

SwiftUI 的局限性

SwiftUI 没有内置的方式来协调这些手势,解决冲突,所以我们必须深入到 UIKit,自行解决冲突。

如何解决

关键点:在第一页时,我们需要两个手势同时激活,但响应不同的方向:

  • 向右滑动(从左边缘) → Navigation 返回手势
  • 向左滑动 → TabView 翻页

当然,这个要实现上述的逻辑,需要通过 UIKit 来进行手势冲突的逻辑处理。

解决方案

完整实现:NavigationSwipeBackModifier.swift

步骤 1:识别手势

获取到互相冲突的两个手势:

  • Navigation Gesture:位于 UINavigationController.interactivePopGestureRecognizer
  • Content Gesture:位于可滚动内容上(如 UIScrollView.panGestureRecognizer)
.introspect(.viewController, on: .iOS(.v16, .v17, .v18)) { viewController in
    guard let navigationController = viewController.navigationController,
          let interactivePopGesture = navigationController.interactivePopGestureRecognizer else {
        return
    }
    coordinator.configure(with: interactivePopGesture)
}
.introspect(.scrollView, on: .iOS(.v16, .v17, .v18)) { scrollView in
    coordinator.conflictingGesture = scrollView.panGestureRecognizer
}

步骤 2:创建 Coordinator

构建一个实现 UIGestureRecognizerDelegate 的 Coordinator,他的职责如下:

  • 存储两个手势
  • 通过 Delegate 回调管理它们的交互
  • 处理生命周期(设置和清理)
public final class NavigationSwipeBackCoordinator: NSObject, UIGestureRecognizerDelegate {
    /// Closure that determines whether swipe-back should be enabled
    public var shouldEnableSwipeBack: (() -> Bool)?

    /// The conflicting gesture that should work simultaneously
    public weak var conflictingGesture: UIPanGestureRecognizer?

    private weak var interactivePopGesture: UIGestureRecognizer?
    private weak var originalDelegate: UIGestureRecognizerDelegate?

    public func configure(with gesture: UIGestureRecognizer) {
        guard interactivePopGesture == nil else { return }
        interactivePopGesture = gesture
        originalDelegate = gesture.delegate
        gesture.delegate = self
    }
    // ... cleanup and delegate methods
}

步骤 3:启用同时识别 RecognizeSimultaneously

实现 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)

  • 当两个手势需要同时工作时返回 true
  • 允许两者检测触摸而不会互相拦截
public func gestureRecognizer(
    _: UIGestureRecognizer,
    shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
    // Only allow simultaneous recognition with the conflicting gesture we're managing
    return otherGestureRecognizer == conflictingGesture
}

步骤 4:添加条件逻辑

实现 gestureRecognizerShouldBegin(_:)

  • 检查当前状态(例如检查是否位于第一页)
  • 只在适当的时候允许 Navigation 手势
  • 在用户应该滚动内容时阻止返回手势
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer else {
        return true
    }

    // Check swipe direction
    let translation = panGesture.translation(in: panGesture.view)
    let velocity = panGesture.velocity(in: panGesture.view)
    let isSwipingRight = translation.x > 0 || velocity.x > 0

    // Only allow back gesture for right swipes
    guard isSwipingRight else { return false }

    // Check app-specific condition (e.g., "am I on the first page?")
    return shouldEnableSwipeBack?() ?? false
}

步骤 5:管理生命周期

  • 设置:保存原始状态,安装自定义 Delegate
  • 清理:恢复原始状态以避免副作用
public func cleanup() {
    interactivePopGesture?.delegate = originalDelegate
    interactivePopGesture = nil
    originalDelegate = nil
    shouldEnableSwipeBack = nil
    conflictingGesture = nil
}

步骤 6:封装为 SwiftUI Modifier

创建可复用的 ViewModifier:

  • 封装所有 UIKit 复杂性
  • 提供简洁的 SwiftUI API
  • 响应式更新状态
public extension View {
    func enableNavigationSwipeBack(when condition: @escaping () -> Bool) -> some View {
        modifier(NavigationSwipeBackModifier(shouldEnable: condition))
    }
}
// Usage
.enableNavigationSwipeBack(when: { selectedIndex == 0 })

实现模式

  ┌─────────────────────────────────────┐
  │   SwiftUI View                      │
  │   .enableSwipeBack(when: condition) │
  └────────────┬────────────────────────┘
               │
               ▼
  ┌─────────────────────────────────────┐
  │   ViewModifier                      │
  │   - Manages lifecycle               │
  │   - Updates condition reactively    │
  └────────────┬────────────────────────┘
               │
               ▼
  ┌─────────────────────────────────────┐
  │   Gesture Coordinator               │
  │   - Implements delegate callbacks   │
  │   - Coordinates both gestures       │
  │   - Stores original state           │
  └─────────────────────────────────────┘

使用方法

在任何会阻止 Navigation 返回手势的横向滑动视图上,应用 enableNavigationSwipeBack modifier。

基本语法

.enableNavigationSwipeBack(when: { condition })

when 闭包用于判断何时应该启用返回手势。它在手势开始时实时计算,确保能响应最新的状态。

示例:分页 TabView

TabView(selection: $selection) {
    ForEach(items) { item in
        ItemView(item: item)
    }
}
.tabViewStyle(.page(indexDisplayMode: .never))
.enableNavigationSwipeBack(when: { selectedItemIndex == 0 })

注意:此方案需要 SwiftUIIntrospect 库来访问底层 UIKit 视图。

效果

当用户位于第一页时,自动允许边缘滑动返回手势

success.gif

SwiftUI快速入门指南-Modifier篇

背景

本文帮助有Swift基础的同学,快速入门SwiftUI,基于cursour整理

主要分为四个部分:

1. 什么是 Modifier?

Modifier 是用于修改视图外观和行为的方法。每个 modifier 都会返回一个新的视图。

Text("Hello")
    .font(.title)           // 修改字体
    .foregroundColor(.blue) // 修改颜色
    .padding()              // 添加内边距
    .background(.yellow)    // 添加背景

核心概念:

  • ✅ Modifier 不修改原视图,而是创建新视图
  • ✅ 支持链式调用
  • ✅ 顺序很重要!

2. Modifier 分类

A. 文本 Modifier
Text("SwiftUI Modifier")
    // 字体
    .font(.title)
    .font(.system(size: 24, weight: .bold, design: .rounded))
    .fontWeight(.semibold)
    
    // 颜色
    .foregroundColor(.blue)
    .foregroundStyle(.red)
    
    // 样式
    .italic()
    .bold()
    .underline()
    .strikethrough()
    .kerning(2)              // 字间距
    .tracking(3)             // 字符间距
    .baselineOffset(5)       // 基线偏移
    
    // 多行
    .lineLimit(3)
    .lineSpacing(8)
    .multilineTextAlignment(.center)
    .truncationMode(.tail)
B. 布局 Modifier
VStack {
    Text("布局示例")
}
// 内边距
.padding()
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20))

// 尺寸
.frame(width: 200, height: 100)
.frame(minWidth: 100, maxWidth: .infinity)
.frame(maxHeight: 300)

// 对齐
.frame(width: 300, height: 200, alignment: .topLeading)

// 偏移
.offset(x: 10, y: 20)

// 位置
.position(x: 100, y: 100)
C. 背景和边框 Modifier
Text("样式示例")
    // 背景
    .background(.blue)
    .background(Color.blue.opacity(0.3))
    .background(
        LinearGradient(
            colors: [.blue, .purple],
            startPoint: .leading,
            endPoint: .trailing
        )
    )
    
    // 边框
    .border(.red, width: 2)
    
    // 圆角边框
    .cornerRadius(10)
    .clipShape(RoundedRectangle(cornerRadius: 15))
    .clipShape(Circle())
    
    // 描边
    .overlay(
        RoundedRectangle(cornerRadius: 10)
            .stroke(.red, lineWidth: 2)
    )
D. 阴影和效果 Modifier
Text("效果示例")
    // 阴影
    .shadow(radius: 5)
    .shadow(color: .gray, radius: 10, x: 5, y: 5)
    
    // 模糊
    .blur(radius: 3)
    
    // 透明度
    .opacity(0.8)
    
    // 旋转
    .rotationEffect(.degrees(45))
    .rotation3DEffect(.degrees(45), axis: (x: 1, y: 0, z: 0))
    
    // 缩放
    .scaleEffect(1.5)
    .scaleEffect(x: 1.2, y: 0.8)
E. 交互 Modifier
Text("点击我")
    // 点击
    .onTapGesture {
        print("被点击了")
    }
    .onTapGesture(count: 2) {
        print("双击")
    }
    
    // 长按
    .onLongPressGesture {
        print("长按")
    }
    
    // 拖拽
    .gesture(
        DragGesture()
            .onChanged { value in
                print("拖拽中")
            }
    )
    
    // 禁用
    .disabled(true)
F. 生命周期 Modifier
Text("生命周期")
    // 出现
    .onAppear {
        print("视图出现")
    }
    
    // 消失
    .onDisappear {
        print("视图消失")
    }
    
    // 值变化
    .onChange(of: someValue) { oldValue, newValue in
        print("值改变了")
    }
    
    // 任务
    .task {
        await loadData()
    }

3. Modifier 顺序的重要性 ⚠️

这是最容易出错的地方!Modifier 的顺序会产生完全不同的结果。

// 示例 1: 先 padding 后 background
Text("Hello")
    .padding(20)        // 先添加内边距
    .background(.blue)  // 背景覆盖整个区域(包括 padding)

// 结果:蓝色背景包含文字和内边距

// 示例 2: 先 background 后 padding
Text("Hello")
    .background(.blue)  // 背景只覆盖文字
    .padding(20)        // 在背景外添加内边距

// 结果:蓝色背景只包含文字,外面有空白

// 边框和圆角
Text("示例")
    .padding()
    .background(.blue)
    .cornerRadius(10)    // ✅ 正确:圆角应用到背景
    .border(.red, width: 2)  // 边框在圆角外

Text("示例")
    .padding()
    .cornerRadius(10)    // ❌ 错误:圆角应用到文字(没效果)
    .background(.blue)
    .border(.red, width: 2)
    
// Frame 和 Background
Text("示例")
    .frame(width: 200, height: 100)
    .background(.blue)   // ✅ 蓝色填满整个 frame

Text("示例")
    .background(.blue)   
    .frame(width: 200, height: 100)  // ❌ 蓝色只在文字周围

4. 自定义 Modifier

方法一:使用 ViewModifier 协议
// 定义自定义 modifier
struct CardModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.white)
            .cornerRadius(10)
            .shadow(color: .gray.opacity(0.4), radius: 5, x: 0, y: 2)
    }
}

// 扩展 View 以便于使用
extension View {
    func cardStyle() -> some View {
        self.modifier(CardModifier())
    }
}

// 使用
Text("卡片样式")
    .cardStyle()
方法二:直接扩展 View
extension View {
    func primaryButton() -> some View {
        self
            .font(.headline)
            .foregroundColor(.white)
            .padding()
            .frame(maxWidth: .infinity)
            .background(.blue)
            .cornerRadius(10)
    }
}

// 使用
Text("登录")
    .primaryButton()
带参数的自定义 Modifier
struct BorderModifier: ViewModifier {
    var color: Color
    var width: CGFloat
    var cornerRadius: CGFloat
    
    func body(content: Content) -> some View {
        content
            .padding()
            .overlay(
                RoundedRectangle(cornerRadius: cornerRadius)
                    .stroke(color, lineWidth: width)
            )
    }
}

extension View {
    func customBorder(
        color: Color = .blue,
        width: CGFloat = 2,
        cornerRadius: CGFloat = 8
    ) -> some View {
        self.modifier(BorderModifier(
            color: color,
            width: width,
            cornerRadius: cornerRadius
        ))
    }
}

// 使用
Text("自定义边框")
    .customBorder(color: .red, width: 3, cornerRadius: 15)

5. 条件 Modifier

// 方法一:使用 @ViewBuilder
extension View {
    @ViewBuilder
    func `if`<Transform: View>(
        _ condition: Bool,
        transform: (Self) -> Transform
    ) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

// 使用
Text("条件样式")
    .if(isHighlighted) { view in
        view
            .font(.largeTitle)
            .foregroundColor(.red)
    }
    
// 方法二:三元运算符
Text("示例")
    .foregroundColor(isActive ? .blue : .gray)
    .font(isLarge ? .title : .body)
    
// 方法三:使用 modifier
struct ConditionalModifier: ViewModifier {
    var condition: Bool
    
    func body(content: Content) -> some View {
        if condition {
            content
                .background(.yellow)
                .cornerRadius(10)
        } else {
            content
                .background(.gray)
        }
    }
}

// 使用
Text("条件")
    .modifier(ConditionalModifier(condition: isSpecial))

6. 组合 Modifier 实战示例

struct ProfileCard: View {
    @State private var isLiked = false
    
    var body: some View {
        VStack(spacing: 12) {
            // 头像
            Image(systemName: "person.circle.fill")
                .resizable()
                .scaledToFit()
                .frame(width: 80, height: 80)
                .foregroundColor(.blue)
                .clipShape(Circle())
                .overlay(
                    Circle()
                        .stroke(.gray, lineWidth: 2)
                )
                .shadow(radius: 5)
            
            // 名字
            Text("张三")
                .font(.title2)
                .fontWeight(.bold)
            
            // 描述
            Text("iOS 开发工程师")
                .font(.subheadline)
                .foregroundColor(.secondary)
            
            // 按钮
            Button(action: { isLiked.toggle() }) {
                HStack {
                    Image(systemName: isLiked ? "heart.fill" : "heart")
                    Text(isLiked ? "已关注" : "关注")
                }
                .font(.headline)
                .foregroundColor(isLiked ? .red : .white)
                .padding(.horizontal, 20)
                .padding(.vertical, 10)
                .background(isLiked ? .white : .blue)
                .cornerRadius(20)
                .overlay(
                    RoundedRectangle(cornerRadius: 20)
                        .stroke(isLiked ? .red : .blue, lineWidth: 2)
                )
            }
        }
        .padding(20)
        .background(.white)
        .cornerRadius(15)
        .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 5)
        .padding()
    }
}

7. 常用 Modifier 组合模板

extension View {
    // 卡片样式
    func card() -> some View {
        self
            .padding()
            .background(.white)
            .cornerRadius(12)
            .shadow(color: .gray.opacity(0.3), radius: 8, x: 0, y: 4)
    }
    
    // 主按钮样式
    func primaryButtonStyle() -> some View {
        self
            .font(.headline)
            .foregroundColor(.white)
            .padding()
            .frame(maxWidth: .infinity)
            .background(
                LinearGradient(
                    colors: [.blue, .purple],
                    startPoint: .leading,
                    endPoint: .trailing
                )
            )
            .cornerRadius(10)
            .shadow(radius: 5)
    }
    
    // 输入框样式
    func textFieldStyle() -> some View {
        self
            .padding()
            .background(.gray.opacity(0.1))
            .cornerRadius(8)
            .overlay(
                RoundedRectangle(cornerRadius: 8)
                    .stroke(.gray.opacity(0.5), lineWidth: 1)
            )
    }
    
    // 标签样式
    func tag(color: Color = .blue) -> some View {
        self
            .font(.caption)
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
            .background(color.opacity(0.2))
            .foregroundColor(color)
            .cornerRadius(12)
    }
}

// 使用示例
struct ContentView: View {
    @State private var email = ""
    
    var body: some View {
        VStack(spacing: 20) {
            // 卡片
            VStack {
                Text("用户信息")
                Text("详细内容")
            }
            .card()
            
            // 输入框
            TextField("邮箱", text: $email)
                .textFieldStyle()
            
            // 按钮
            Text("登录")
                .primaryButtonStyle()
            
            // 标签
            HStack {
                Text("热门").tag(color: .red)
                Text("新品").tag(color: .green)
                Text("推荐").tag(color: .blue)
            }
        }
        .padding()
    }
}

8. 高级 Modifier 技巧

A. 环境 Modifier

影响所有子视图:

VStack {
    Text("标题")
    Text("副标题")
    Text("内容")
}
.font(.title)          // 所有子视图都使用 title 字体
.foregroundColor(.blue) // 所有子视图都是蓝色
B. 几何读取器配合 Modifier
GeometryReader { geometry in
    Text("响应式")
        .frame(width: geometry.size.width * 0.8)
        .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
}
C. 动画 Modifier
struct AnimatedView: View {
    @State private var isExpanded = false
    
    var body: some View {
        RoundedRectangle(cornerRadius: isExpanded ? 50 : 10)
            .fill(isExpanded ? .blue : .red)
            .frame(width: isExpanded ? 200 : 100, height: 100)
            .animation(.spring(response: 0.5, dampingFraction: 0.6), value: isExpanded)
            .onTapGesture {
                isExpanded.toggle()
            }
    }
}

9. Modifier 最佳实践

 推荐做法
// 1. 提取重复的 modifier 为自定义 modifier
extension View {
    func standardCard() -> some View {
        self
            .padding()
            .background(.white)
            .cornerRadius(10)
            .shadow(radius: 5)
    }
}

// 2. 注意顺序
Text("示例")
    .padding()
    .background(.blue)
    .cornerRadius(10)  // 正确顺序

// 3. 使用语义化命名
extension View {
    func errorStyle() -> some View {
        self.foregroundColor(.red).bold()
    }
    
    func successStyle() -> some View {
        self.foregroundColor(.green).bold()
    }
}

 避免做法
// 1. 避免过长的 modifier 链
Text("Bad")
    .font(.title).foregroundColor(.blue).padding().background(.yellow).cornerRadius(10).shadow(radius: 5).opacity(0.9)
    // 太长了!应该换行

// 2. 避免重复代码
Text("Button 1")
    .padding()
    .background(.blue)
    .cornerRadius(10)

Text("Button 2")
    .padding()
    .background(.blue)
    .cornerRadius(10)
// 应该提取为自定义 modifier

// 3. 避免错误的顺序
Text("Wrong")
    .cornerRadius(10)   // 错误:在 background 之前
    .background(.blue)

总结

Modifier 核心要点:

  • ✅ Modifier 创建新视图,不修改原视图
  • ✅ 顺序非常重要
  • ✅ 支持链式调用
  • ✅ 可以自定义和复用
  • ✅ 使用语义化命名
  • ✅ 注意性能(避免过度嵌套)

SwiftUI快速入门指南-关键字篇

背景

本文帮助有Swift基础的同学,快速入门SwiftUI,基于cursour整理

主要分为四个部分:

Some

some 表示"某个特定的类型,该类型遵循某个协议"。它的特点是:

  • 隐藏具体类型:调用者不知道具体是什么类型,只知道它遵循某个协议
  • 类型固定:返回的始终是同一个具体类型(编译器知道)
  • 类型推断:编译器会自动推断出具体类型

some vs any 核心区别

特性 some any
类型确定 编译时确定,固定不变 运行时可变
性能 快(静态派发) 慢(动态派发,有装箱开销)
类型一致性 必须始终返回同一类型 可以返回不同类型
引入版本 Swift 5.1 Swift 5.6
使用场景 返回类型、属性 需要类型灵活性时
// some - 固定的具体类型
func makeSomeView() -> some View {
    Text("Hello")  // 每次调用都返回 Text 类型
}

// any - 可以是任何符合协议的类型
func makeAnyView(condition: Bool) -> any View {
    if condition {
        return Text("Hello")   // 这次返回 Text
    } else {
        return Image("icon")   // 下次可能返回 Image
    }
}

关键字

属性包装器 用途 拥有数据 数据类型 典型场景
@State 当前View状态处理 ✅ 是 值类型 简单的 UI 状态
@Binding 父子View间状态传递 ❌ 否 任意 子视图修改父状态
@StateObject 当前View引用对象,对象的生命周期在当前View ✅ 是 引用类型 视图的 ViewModel
@ObservedObject 父子View间对象状态传递,对象在父View ❌ 否 引用类型 传入的对象
@EnvironmentObject 跨View间状态传递 ❌ 否 引用类型 全局共享数据
@Environment 系统环境 ❌ 否 系统提供 系统设置和服务

1. @State - 私有状态 用于管理视图内部的简单值类型状态。

struct CounterView: View {
    @State private var count = 0
    @State private var isOn = false
    @State private var name = ""
    
    var body: some View {
        VStack {
            Text("计数: \(count)")
            Button("增加") {
                count += 1  // 修改会触发视图刷新
            }
            
            Toggle("开关", isOn: $isOn)
            TextField("姓名", text: $name)
        }
    }
}

特点:

  • ✅ 用于值类型(Int, String, Bool, struct 等)
  • ✅ 视图拥有这个状态
  • ✅ 声明为 private
  • ✅ SwiftUI 管理其生命周期
  • ✅ 修改会自动刷新视图

2. @Binding - 双向绑定

创建对父视图状态的双向绑定。

struct ParentView: View {
    @State private var isPresented = false
    
    var body: some View {
        VStack {
            Button("显示") {
                isPresented = true
            }
            
            // 传递绑定
            ChildView(isPresented: $isPresented)
        }
    }
}

struct ChildView: View {
    @Binding var isPresented: Bool  // 绑定到父视图的状态
    
    var body: some View {
        Toggle("显示状态", isOn: $isPresented)
        // 修改会同步到父视图
    }
}

特点:

  • ✅ 创建双向连接
  • ✅ 子视图可以读写父视图的状态
  • ✅ 使用 $ 传递绑定
  • ✅ 不拥有数据

3. @StateObject - 引用类型的拥有者

用于创建和拥有 ObservableObject 实例

// 1. 创建可观察对象
class ViewModel: ObservableObject {
    @Published var items: [String] = []
    @Published var isLoading = false
    
    func loadData() {
        isLoading = true
        // 加载数据...
        items = ["Item 1", "Item 2"]
        isLoading = false
    }
}

// 2. 在视图中使用
struct ContentView: View {
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        List(viewModel.items, id: \.self) { item in
            Text(item)
        }
        .onAppear {
            viewModel.loadData()
        }
    }
}

3. @ObservedObject - 引用类型的观察者

用于观察已存在的 ObservableObject(不拥有)。

class ViewModel: ObservableObject {
    @Published var count = 0
}

struct ParentView: View {
    @StateObject private var viewModel = ViewModel()  // 拥有
    
    var body: some View {
        ChildView(viewModel: viewModel)  // 传递
    }
}

struct ChildView: View {
    @ObservedObject var viewModel: ViewModel  // 观察(不拥有)
    
    var body: some View {
        VStack {
            Text("计数: \(viewModel.count)")
            Button("增加") {
                viewModel.count += 1
            }
        }
    }
}

特点:

  • ✅ 观察从外部传入的对象
  • ❌ 不拥有对象
  • ⚠️ 视图重建时可能导致对象重新初始化(如果使用不当)

5. @EnvironmentObject - 环境对象 在视图层级中共享对象,无需逐层传递。

class UserSettings: ObservableObject {
    @Published var username = "Guest"
    @Published var isDarkMode = false
}

@main
struct MyApp: App {
    @StateObject private var settings = UserSettings()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(settings)  // 注入
        }
    }
}

struct ContentView: View {
    @EnvironmentObject var settings: UserSettings  // 自动获取
    
    var body: some View {
        VStack {
            Text("用户: \(settings.username)")
            SettingsView()  // 子视图也能访问
        }
    }
}

struct SettingsView: View {
    @EnvironmentObject var settings: UserSettings  // 直接访问
    
    var body: some View {
        Toggle("深色模式", isOn: $settings.isDarkMode)
    }
}

特点:

  • ✅ 跨层级共享数据
  • ✅ 无需逐层传递
  • ⚠️ 如果未注入会崩溃
  • ✅ 适合全局状态(用户设置、主题等)

6. @Environment - 系统环境值

访问 SwiftUI 提供的系统环境值。

struct MyView: View {
    @Environment(\.colorScheme) var colorScheme  // 深色/浅色模式
    @Environment(\.dismiss) var dismiss  // 关闭动作
    @Environment(\.horizontalSizeClass) var sizeClass  // 尺寸类别
    
    var body: some View {
        VStack {
            Text("当前模式: \(colorScheme == .dark ? "深色" : "浅色")")
            
            Button("关闭") {
                dismiss()
            }
        }
    }
}

常用环境值:

  • .colorScheme - 颜色方案
  • .dismiss - 关闭当前视图
  • .horizontalSizeClass / .verticalSizeClass - 尺寸类别
  • .locale - 本地化
  • .accessibilityEnabled - 辅助功能

最佳实践

// 1. 简单值用 @State
@State private var count = 0

// 2. 创建对象用 @StateObject
@StateObject private var viewModel = ViewModel()

// 3. 传递对象用 @ObservedObject
@ObservedObject var viewModel: ViewModel

// 4. 传递绑定用 @Binding
@Binding var isPresented: Bool

// 5. 全局共享用 @EnvironmentObject
@EnvironmentObject var settings: AppSettings

iOS一个Fancy UI的Tricky实现

背景

最近接到了一个Fancy的动效UI,主要是为了在首屏放出更多有用信息,提升用户购买转化率

这也是我近几年遇到的一个相对复杂的UI效果了。一开始看到这个效果,其实心里是没有底能不能实现的。因为在我github star的1.4k+库中,就没有见过类似的效果,而且单从视频看下来,有物理上的滑动冲突。但是别无选择,最终还是通过各种demo实验,把效果实现了。下面就给大家介绍一下实现的方式tricky在哪里

设计效果

那么这个效果Fancy在哪里呢?我们来拆解一下:

  • 可以看到头部图片区域在上滑的时候有一个放大的效果,头部区域有高斯模糊和渐变效果
  • 主要信息区域有一个Title的展开Alpha渐变动画
  • 在列表上滑,在头部放大,Title展开的同时,列表还可能往下顶

头部图片放大效果实现

其实同步的放大效果,相对来说是比较简单的,就是一个上滑的偏移量变化,计算出上滑放大的效果

Screenshot 2023-09-24 at 15.41.04.png

上滑的进度 = 当前上滑距离 / 可以上滑距离

可以上滑距离 = P2 - P1

当前上滑距离 = contentOffsetY (系统UI控件可以获取)

头图高度 = min(最小高度 + (最大高度 - 最小高度) * 上滑进度, 最大高度)

最小高度 = 半屏时头图的高度,默认是200pt

最大高度 = 全屏时屏幕的宽度,因为头图的最大尺寸宽高比是1:1

聪明的同学会发现,上面的公式中,在满足 最小高度 + (最大高度 - 最小高度) * 上滑进度 < 最大高度 时

有可能 (最大高度 - 最小高度) * 上滑进度 > 可以上滑距离

这个点,其实也是我在看到这个效果时比较担心的一个点,因为这个时候手指在屏幕上往上推,但视图却在往下顶,是不跟手的状态。

好在真机体验没有明显的体感问题,所以也没有什么特殊处理

为什么这里需要用一个上滑的进度,而不用上滑的绝对值呢?其实我一开始用的是绝对值,但是在(最大高度 - 最小高度) * 上滑进度 > 可以上滑距离时,直接把剩余的高度暴力加上,就会出现一个严重的跳动效果。

文字展开动画效果实现

这部分也是整个效果最难的,那么他到底难在哪里?下面我给大家拆解一下

首先iOS的文字UI控件,是没法做到视频中逐行展开并且带有Alpha动画的。

那么系统的控件实现不了,有什么其他办法呢?脑海里疯狂回忆我star的1.4k+库里面搜寻类似效果,结果当然是无果 又是一顿Google搜索,iOS expandable UILabel animationiOS expandable UILabel...,换了各种关键字,结果都没有找到好的解决方案。

只能硬着头皮自己想。

首先我不考虑展开效果和Alpha动画的事情,先做到,从一行上滑时变成多行。

初始效果.gif

达到这个效果还是比较简单的,我们只需要把Title label的展示行数设置成无数行,然后高度强制设置成一行的高度,滑动的时候用类似头部放大效果的公式,即可达到该效果

到这里,我内心稍微放松了一下,想的是终于有一个可以保底交付的效果了,展开动效的要是做不了,就用这个交付吧。。。

我想啊想啊想,逐行展开,逐行展开。关键是先要逐行,逐行之后再做y坐标偏移动画就简单了。

那么我能不能把文字UI控件截图,然后逐行裁剪做动画呢?

管他的,先搞个demo试试

我擦,牛逼呀,这个方法可以诶。再来看看这个方法的原理

Screenshot 2023-09-24 at 17.08.59.png

  • 第一步把文字部分生成一张图片
  • 计算出有多少行文字
  • 将每一行文字裁切成一张图片

最终效果

done.gif

完美啊!

《Flutter全栈开发实战指南:从零到高级》- 18 -自定义绘制与画布

引言

不知道大家是否曾有过这样的困扰:UI设计稿里出了一个特别炫酷的进度条,用现有组件怎么都拼不出来?产品经理又要求开发一个复杂的动态几何图形背景?或者需要实现一个画板功能等等。当你遇到这些情况时,别急!这些复杂效果都可以通过自定义绘制来实现,今天的内容带你深入理解这些复杂效果的背后原理。

image.png

一、绘制系统的底层架构

1.1 Flutter绘制整体架构

在深入自定义绘制之前,我们需要理解Flutter绘制系统的整体架构。这不仅仅是API调用,更是一个完整的渲染管线。

graph TB
    A[Widget Tree] --> B[RenderObject Tree]
    B --> C[Layout Phase]
    C --> D[Paint Phase]
    D --> E[Canvas Operations]
    E --> F[Skia Engine]
    F --> G[GPU]
    G --> H[Screen Display]
    
    subgraph "Flutter Framework"
        A
        B
        C
        D
        E
    end
    
    subgraph "Embedder"
        F
        G
        H
    end

1.2 渲染管线详细工作流程

下面通过一个详细的序列图来辅助理解整个绘制过程:

sequenceDiagram
    participant W as Widget
    participant R as RenderObject
    participant P as PaintingContext
    participant C as Canvas
    participant S as Skia
    participant G as GPU

    W->>R: createRenderObject()
    R->>R: layout()
    R->>P: paint()
    P->>C: save layer
    C->>S: draw calls
    S->>G: OpenGL/Vulkan
    G->>G: rasterization
    G->>G: frame buffer
    G->>Screen: display frame

二、CustomPaint与Canvas的原理

2.1 CustomPaint在渲染树中的位置

// CustomPaint的内部结构
class CustomPaint extends SingleChildRenderObjectWidget {
  final CustomPainter? painter;
  final CustomPainter? foregroundPainter;
  
  @override
  RenderCustomPaint createRenderObject(BuildContext context) {
    return RenderCustomPaint(
      painter: painter,
      foregroundPainter: foregroundPainter,
    );
  }
}

class RenderCustomPaint extends RenderProxyBox {
  CustomPainter? _painter;
  CustomPainter? _foregroundPainter;
  
  @override
  void paint(PaintingContext context, Offset offset) {
    // 1. 先绘制背景painter
    if (_painter != null) {
      _paintWithPainter(context.canvas, offset, _painter!);
    }
    
    // 2. 绘制子节点
    super.paint(context, offset);
    
    // 3. 最后绘制前景painter  
    if (_foregroundPainter != null) {
      _paintWithPainter(context.canvas, offset, _foregroundPainter!);
    }
  }
}

2.2 Canvas的底层实现机制

Canvas实际上更像一个命令录制器,它并不立即执行绘制操作,而是记录所有的绘制命令,在适当的时候批量执行。

graph LR
    A1[drawCircle] --> B[Canvas<br/>命令缓冲区]
    A2[drawPath] --> B
    A3[drawRect] --> B
    A4[drawText] --> B
    
    B --> C[SkPicture<br/>持久化存储]
    
    C --> D1[SkCanvas<br/>软件渲染]
    C --> D2[GPU<br/>硬件渲染]
    
    D1 --> E[CPU渲染结果]
    D2 --> F[GPU渲染结果]
    
    E --> G[屏幕输出]
    F --> G
    
    subgraph API_LAYER [Canvas API]
        A1
        A2
        A3
        A4
    end
    
    subgraph RECORD_LAYER [录制层]
        B
        C
    end
    
    subgraph RENDER_LAYER [渲染层]
        D1
        D2
    end

Canvas的核心数据结构:

// Canvas内部结构
class Canvas {
  final SkCanvas _skCanvas;
  final List<SaveRecord> _saveStack = [];
  
  // SkCanvas负责所有的绘制操作
  void drawCircle(Offset center, double radius, Paint paint) {
    _skCanvas.drawCircle(
      center.dx, center.dy, radius, 
      paint._toSkPaint()  // 将Dart的Paint转换为Skia的SkPaint
    );
  }
}

三、RenderObject与绘制的关系

3.1 渲染树的绘制流程

每个RenderObject都有机会参与绘制过程,理解这个过程对性能优化至关重要。

abstract class RenderObject extends AbstractNode {
  void paint(PaintingContext context, Offset offset) {
    // 默认实现:如果有子节点就绘制子节点
    // 自定义RenderObject可以重写这个方法
  }
}

class PaintingContext {
  final ContainerLayer _containerLayer;
  final Canvas _canvas;
  
  void paintChild(RenderObject child, Offset offset) {
    // 递归
    child._paintWithContext(this, offset);
  }
}

3.2 图层合成原理

Flutter使用图层合成技术来提高渲染性能。理解图层对于处理复杂绘制场景非常重要。

graph TB
    A[Root Layer] --> B[Transform Layer]
    B --> C[Opacity Layer]
    C --> D[Layer 1]
    C --> E[Layer 2]
    
    subgraph "图层树结构"
        B
        C
        D
        E
    end

图层的重要性:

  • 独立的绘制操作被记录在不同的PictureLayer中
  • 当只有部分内容变化时,只需重绘对应的图层
  • 硬件合成可以高效地组合这些图层

四、Paint对象的内部机制

4.1 Paint的Skia底层对应

class Paint {
  Color? color;
  PaintingStyle? style;
  double? strokeWidth;
  BlendMode? blendMode;
  Shader? shader;
  MaskFilter? maskFilter;
  ColorFilter? colorFilter;
  ImageFilter? imageFilter;
  
  // 将Dart的Paint转换为Skia的SkPaint
  SkPaint _toSkPaint() {
    final SkPaint skPaint = SkPaint();
    if (color != null) {
      skPaint.color = color!.value;
    }
    if (style == PaintingStyle.stroke) {
      skPaint.style = SkPaintStyle.stroke;
    }
    skPaint.strokeWidth = strokeWidth ?? 1.0;
    return skPaint;
  }
}

4.2 Shader的工作原理

Shader是Paint中最强大的功能之一,理解其工作原理可以写出更炫酷的视觉效果。

// 线性渐变的底层实现原理
class LinearGradient extends Shader {
  final Offset start;
  final Offset end;
  final List<Color> colors;
  
  @override
  SkShader _createShader() {
    final List<SkColor> skColors = colors.map((color) => color.value).toList();
    return SkShader.linearGradient(
      start.dx, start.dy, end.dx, end.dy,
      skColors,
      _computeColorStops(),
      SkTileMode.clamp,
    );
  }
}

Shader的GPU执行流程:

  1. CPU准备Shader参数;
  2. 上传到GPU的纹理内存;;
  3. 片段着色器执行插值计算;
  4. 输出到帧缓冲区;

五、Path的原理与实现

5.1 贝塞尔曲线

贝塞尔曲线是计算机图形学的基础,理解其数学原理有助于创建更复杂的图形。

// 贝塞尔曲线
Path _flattenCubicBezier(Offset p0, Offset p1, Offset p2, Offset p3, double tolerance) {
  final Path path = Path();
  path.moveTo(p0.dx, p0.dy);
  
  // 将曲线离散化为多个线段
  for (double t = 0.0; t <= 1.0; t += 0.01) {
    final double x = _cubicBezierPoint(p0.dx, p1.dx, p2.dx, p3.dx, t);
    final double y = _cubicBezierPoint(p0.dy, p1.dy, p2.dy, p3.dy, t);
    path.lineTo(x, y);
  }
  
  return path;
}

5.2 Path的底层数据结构

Path在底层使用路径段的链表结构来存储:

// Path段类型
enum PathSegmentType {
  moveTo,
  lineTo, 
  quadraticTo,
  cubicTo,
  close,
}

class PathSegment {
  final PathSegmentType type;
  final List<Offset> points;
  final PathSegment? next;
}

六、性能优化的底层原理

6.1 RepaintBoundary的工作原理

RepaintBoundary是Flutter性能优化的关键,它创建了独立的图层。

class RepaintBoundary extends SingleChildRenderObjectWidget {
  @override
  RenderRepaintBoundary createRenderObject(BuildContext context) {
    return RenderRepaintBoundary();
  }
}

class RenderRepaintBoundary extends RenderProxyBox {
  @override
  bool get isRepaintBoundary => true; // 关键属性
  
  @override
  void paint(PaintingContext context, Offset offset) {
    // 如果内容没有变化,可以复用之前的绘制结果
    if (_needsPaint) {
      _layer = context.pushLayer(
        PictureLayer(Offset.zero),
        super.paint,
        offset,
        childPaintBounds: paintBounds,
      );
    } else {
      context.addLayer(_layer!);
    }
  }
}

6.2 图层复用机制

sequenceDiagram
    participant A as Frame N
    participant B as RepaintBoundary
    participant C as PictureLayer
    participant D as Frame N+1
    
    A->>B: paint()
    B->>C: 录制绘制命令
    C->>C: 生成SkPicture
    
    D->>B: paint() 检查脏区域
    B->>B: 判断是否需要重绘
    alt 需要重绘
        B->>C: 重新录制
    else 不需要重绘
        B->>C: 复用之前的SkPicture
    end

七、实现一个粒子系统

让我们用所学的底层知识实现一个高性能的粒子系统。

7.1 架构设计

class ParticleSystem extends CustomPainter {
  final List<Particle> _particles = [];
  final Stopwatch _stopwatch = Stopwatch();
  
  @override
  void paint(Canvas canvas, Size size) {
    final double deltaTime = _stopwatch.elapsedMilliseconds / 1000.0;
    _stopwatch.reset();
    _stopwatch.start();
    
    _updateParticles(deltaTime);
    _renderParticles(canvas);
  }
  
  void _updateParticles(double deltaTime) {
    for (final particle in _particles) {
      // 模拟位置、速度、加速度
      particle.velocity += particle.acceleration * deltaTime;
      particle.position += particle.velocity * deltaTime;
      particle.lifeTime -= deltaTime;
    }
    
    _particles.removeWhere((particle) => particle.lifeTime <= 0);
  }
  
  void _renderParticles(Canvas canvas) {
    // 使用saveLayer实现粒子混合效果
    canvas.saveLayer(null, Paint()..blendMode = BlendMode.srcOver);
    
    for (final particle in _particles) {
      _renderParticle(canvas, particle);
    }
    
    canvas.restore();
  }
  
  void _renderParticle(Canvas canvas, Particle particle) {
    final Paint paint = Paint()
      ..color = particle.color.withOpacity(particle.alpha)
      ..maskFilter = MaskFilter.blur(BlurStyle.normal, particle.radius);
    
    canvas.drawCircle(particle.position, particle.radius, paint);
  }
  
  @override
  bool shouldRepaint(ParticleSystem oldDelegate) => true;
}

7.2 性能优化技巧

对象池模式:

class ParticlePool {
  final List<Particle> _pool = [];
  int _index = 0;
  
  Particle getParticle() {
    if (_index >= _pool.length) {
      _pool.add(Particle());
    }
    return _pool[_index++];
  }
  
  void reset() => _index = 0;
}

批量绘制优化:

void _renderParticlesOptimized(Canvas canvas) {
  // 使用drawVertices进行批量绘制
  final List<SkPoint> positions = [];
  final List<SkColor> colors = [];
  
  for (final particle in _particles) {
    positions.add(SkPoint(particle.position.dx, particle.position.dy));
    colors.add(particle.color.value);
  }
  
  final SkVertices vertices = SkVertices(
    SkVerticesVertexMode.triangles,
    positions,
    colors: colors,
  );
  
  canvas.drawVertices(vertices, BlendMode.srcOver, Paint());
}

八、自定义渲染管线

对于性能要求非常高的场景,我们可以绕过CustomPaint,直接操作渲染管线。

8.1 自定义RenderObject

class CustomCircleRenderer extends RenderBox {
  Color _color;
  
  CustomCircleRenderer({required Color color}) : _color = color;
  
  @override
  void performLayout() {
    size = constraints.biggest;
  }
  
  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;
    final Paint paint = Paint()..color = _color;
    
    // 操作Canvas控制绘制过程
    canvas.save();
    canvas.translate(offset.dx, offset.dy);
    canvas.drawCircle(size.center(Offset.zero), size.width / 2, paint);
    canvas.restore();
  }
}

8.2 与平台通道集成

对于特别复杂的图形,可以考虑使用平台通道调用原生图形API:

class NativeRenderer extends CustomPainter {
  static const MethodChannel _channel = MethodChannel('native_renderer');
  
  @override
  void paint(Canvas canvas, Size size) async {
    final ByteData? imageData = await _channel.invokeMethod('render', {
      'width': size.width,
      'height': size.height,
    });
    
    if (imageData != null) {
      final Uint8List bytes = imageData.buffer.asUint8List();
      final Image image = await decodeImageFromList(bytes);
      canvas.drawImage(image, Offset.zero, Paint());
    }
  }
}

总结

通过深度剖析Flutter绘制系统的底层原理,我们不仅学会了如何使用CustomPaint和Canvas,更重要的是理解了:渲染管线图层架构 、Skia集成性能优化 ,掌握了这些底层原理,你就能在遇到复杂绘制需求时游刃有余。

如果觉得这篇文章对你有帮助,别忘了一键三连(点赞、关注、收藏)!有任何问题,欢迎评论区留言!!

❌