EDUCBA-Swift-滑动匹配-IOS-应用开发笔记-全-
EDUCBA Swift 滑动匹配 IOS 应用开发笔记(全)
001:Tinder克隆应用入门 🚀
在本教程中,我们将学习如何使用Swift为iOS移动应用创建一个Tinder克隆应用。你将学习到视图控制器、委托、约束以及如何添加各种手势(如滑动、点击、平移、捏合)。本教程难度为中级,适合已经熟悉Swift基础概念、并对iOS应用开发感兴趣的学生。
概述
Tinder是一款基于地理位置社交搜索的移动应用和移动网页应用,主要用于约会和社交。它允许用户通过滑动操作来表达兴趣:向右滑动表示“喜欢”,向左滑动表示“不喜欢”。如果双方互相“喜欢”,则可以进一步聊天。我们将开发一个类似的应用,使用静态用户数据,实现卡片滑动功能。
开始实践

首先,打开Xcode 10.3,创建一个新项目。
以下是创建项目的步骤:
- 点击“Create a new Xcode project”。
- 选择“Single View Application”。
- 点击“Next”。
- 输入产品名称(例如:TinderCloneApp)和组织名称。
- 组织标识符将用于生成唯一的Bundle ID,该ID用于在App Store上标识你的应用。
- 选择语言为Swift。
- 点击“Next”,选择项目保存位置。
- 确保勾选“Create Git repository on my Mac”以在本地设置Git仓库。
- 点击“Create”。
项目创建完成后,我们将开始设计界面。
设计故事板
首先,删除默认的视图控制器。我们需要一个标签栏控制器。
以下是设置标签栏控制器的步骤:
- 从对象库中拖拽一个“Tab Bar Controller”到故事板。
- 标签栏控制器需要至少关联两个视图控制器。
- 选择第一个标签栏项目,在属性检查器中设置其标题和图片。
- 我们需要为标签栏项目添加图片资源。进入Assets.xcassets,创建新的图片集,并添加1x、2x、3x尺寸的图片(例如:home, heart)。
- 为两个标签栏项目分别设置图片和标题。
- 可以调整标签栏的色调颜色以匹配Tinder的风格。
设置应用图标
应用图标需要多种尺寸以适应不同场景(如主屏幕、通知、设置、搜索)。
以下是添加应用图标的步骤:
- 在Assets.xcassets中,找到“AppIcon”位置。
- 根据每个槽位要求的尺寸(例如:40x40, 60x60, 83.5x83.5),拖拽对应尺寸的图片到相应位置。
- 确保没有出现尺寸警告,这表示图标尺寸正确。
- 这些图标将分别用于iPhone通知、设置、Spotlight搜索和主屏幕。
设置完成后,运行应用。虽然目前可能只显示黑屏(因为尚未设置入口点),但你可以在设备的Spotlight搜索中看到已设置的应用图标。




总结




本节课中,我们一起学习了Tinder克隆应用的入门知识。我们创建了新的Xcode项目,设置了标签栏控制器的基础界面,并配置了应用图标。下一节,我们将开始构建应用的主界面和卡片视图。
002:卡片视图设计 🃏

在本节课中,我们将学习如何为滑动匹配应用设计核心的卡片视图。我们将从设置应用的入口点开始,逐步创建卡片容器、卡片视图以及相关的协议和数据源,最终构建出可滑动的用户资料卡片界面。


设置应用入口点

上一节我们完成了项目的基础搭建,本节我们首先来解决运行应用时出现的黑屏警告。这个警告是因为没有设置应用的初始视图控制器。




警告信息是“未能实例化默认视图控制器”。这意味着故事板的入口点没有设置。




可以看到,故事板中没有入口点箭头。这就是运行时显示黑屏的原因。

以下是设置入口点的步骤:
- 在项目导航器中,选择
Main.storyboard文件。 - 在画布上,选中你想要作为应用启动时第一个显示的视图控制器。
- 在右侧的“属性检查器”中,勾选 “Is Initial View Controller” 选项。



勾选后,你会在该视图控制器的左侧看到一个箭头。这个箭头就代表应用的入口点。




设置完成后,再次运行应用,屏幕将变为白色。

自定义标签栏
现在屏幕是白色的,因为标签栏的两个标签项(Tab Bar Item 1 和 2)背景都是白色。我们来自定义一下它们的颜色。

以下是自定义标签栏的步骤:
- 在故事板中,选中第一个标签栏项目(Tab Bar Item 1)。
- 在“属性检查器”中,找到 “Image Tint” 属性。
- 将颜色更改为你喜欢的颜色,例如粉色。



运行应用,你将看到标签栏图标的颜色变成了粉色。

连接视图控制器与类
现在,我们来为第一个标签页的视图控制器添加功能。这个视图控制器将显示可滑动的用户资料卡片。

我们需要将这个视图控制器与一个Swift类关联起来。
以下是关联的步骤:
- 在故事板中,选中第一个标签页的视图控制器。
- 在右侧的“身份检查器”中,找到 “Class” 字段。
- 输入
ViewController并按回车键。
这样,ViewController 类中的代码就可以控制这个界面了。我们的目标是创建5到6个用户资料卡片,每张卡片包含用户照片、姓名、职业和一个信息按钮。用户可以通过向左或向右滑动来浏览这些卡片。当所有卡片都被滑过后,我们将显示一条提示信息。在顶部,我们还会显示浏览每个用户的日期和时间。
创建卡片容器视图
接下来,我们开始设计卡片视图。首先创建一个卡片容器视图,它将承载所有可滑动的卡片。

以下是创建卡片容器类的步骤:
- 点击 File -> New -> File...。
- 选择 Swift File,点击 Next。
- 将文件命名为
SwipeableCardContainerView,点击 Create。
在新创建的Swift文件中,定义一个类:
import UIKit

class SwipeableCardContainerView: UIView {
override func awakeFromNib() {
super.awakeFromNib()
// 初始化代码将写在这里
}
}
awakeFromNib() 方法会在视图从故事板或XIB文件加载完成后被调用,适合进行一些初始化设置。
定义卡片交互协议
为了让卡片能够响应用户的滑动和点击操作,我们需要定义一些协议(Protocol)。协议就像一份合同,规定了需要实现哪些功能。

首先,创建一个处理滑动事件的协议。

以下是创建滑动代理协议的步骤:
- 新建一个Swift文件,命名为
SwipeableViewDelegate。 - 在文件中定义协议:
import Foundation
protocol SwipeableViewDelegate: class {
func didBeginSwipe(onView view: UIView)
func didEndSwipe(onView view: UIView)
}
这个协议声明了两个方法:didBeginSwipe(开始滑动时调用)和 didEndSwipe(结束滑动时调用)。方法的签名(参数和返回值)可能会在后续开发中调整。
为了让 SwipeableCardContainerView 遵循这个协议,我们使用扩展(Extension)来组织代码:
extension SwipeableCardContainerView: SwipeableViewDelegate {
func didBeginSwipe(onView view: UIView) {
// 处理开始滑动的逻辑
}
func didEndSwipe(onView view: UIView) {
// 处理结束滑动的逻辑
}
}
接着,创建一个处理卡片选中事件的协议。

以下是创建卡片选中代理协议的步骤:
- 新建一个Swift文件,命名为
SwipeableCardViewDelegate。 - 在文件中定义协议:
import Foundation

protocol SwipeableCardViewDelegate: class {
func didSelect(card: SwipeableCardViewCard)
}
didSelect 方法将在用户点击卡片时被调用。

定义卡片数据源协议

数据源协议负责向卡片容器提供数据,例如总共有多少张卡片、每张卡片的内容是什么。


以下是创建数据源协议的步骤:
- 新建一个Swift文件,命名为
SwipeableCardViewDataSource。 - 在文件中定义协议:
import UIKit
protocol SwipeableCardViewDataSource: class {
// 返回卡片的总数量
func numberOfCards() -> Int
// 返回指定索引位置的卡片视图
func card(forItemAt index: Int) -> SwipeableCardViewCard
// 当没有卡片可显示时返回的视图
func viewForEmptyCard() -> UIView
}
numberOfCards: 返回应用中可用的卡片总数。注意,同一时间通常只显示最上面的一张卡片。card(forItemAt:): 根据索引返回对应的卡片视图对象。viewForEmptyCard: 当用户滑完了所有卡片后,显示一个空状态视图。
创建卡片视图
现在,我们来创建代表单个用户资料的卡片视图本身。


首先,创建一个关联的XIB文件来设计卡片界面。

以下是创建卡片XIB文件的步骤:
- 点击 File -> New -> File...。
- 选择 Empty 文件模板,点击 Next。
- 将文件命名为
SwipeableCardViewCard.xib,点击 Create。
在XIB文件中进行界面设计:
- 从对象库中拖拽一个 UIView 到画布上。
- 在“属性检查器”中,将 Size 设置为 Freeform。
- 将视图的宽度设置为335,高度设置为400。
- 为视图添加自动布局约束,确保它在不同屏幕尺寸上能正确适配。
- 可以修改视图的 Background 颜色以便预览。


接下来,创建对应的Swift类来管理这个XIB。

以下是创建卡片视图类的步骤:
- 新建一个Swift文件,命名为
SwipeableCardViewCard。 - 定义一个继承自
UIView的类,并实现必要的初始化方法:

import UIKit
class SwipeableCardViewCard: UIView {
// 从故事板或XIB加载时使用的初始化器
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
// 用代码创建视图时使用的初始化器
override init(frame: CGRect) {
super.init(frame: frame)
}
}
- 回到
SwipeableCardViewCard.xib文件。 - 选中画布上的根视图(UIView)。
- 在“身份检查器”中,将 Class 设置为
SwipeableCardViewCard。

这样,XIB文件就和Swift类关联起来了。
创建可滑动的卡片视图基类
最后,我们创建一个可滑动视图的基类,它将处理滑动手势和动画。

以下是创建可滑动视图基类的步骤:
- 新建一个Swift文件,命名为
SwipeableView。 - 定义类并添加一个代理属性:

import UIKit
class SwipeableView: UIView {
// 滑动代理,用于通知滑动开始和结束事件
weak var delegate: SwipeableViewDelegate?
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override init(frame: CGRect) {
super.init(frame: frame)
}
}
required init?(coder:) 和 override init(frame:) 的区别:
required关键字表示,任何继承自SwipeableView的子类都必须实现这个初始化器。这通常用于支持从故事板或XIB文件加载视图。override关键字表示,这个初始化器重写了父类UIView的指定初始化器,用于通过代码创建视图时设置其框架(frame)。
在这个类中,我们声明了一个 delegate 属性,类型是我们之前定义的 SwipeableViewDelegate。后续我们将在这里添加手势识别器(UIPanGestureRecognizer)来处理用户的拖拽滑动操作,并管理卡片的动画效果。

本节课中我们一起学习了如何为滑动匹配应用构建卡片视图系统。我们从设置应用入口点开始,自定义了标签栏,然后创建了卡片容器视图、定义了处理交互的代理协议和数据源协议,最后设计了卡片视图的界面和基类。下一节,我们将实现卡片的滑动逻辑和动画效果。
003:滑动动画
在本节课中,我们将学习如何为滑动卡片实现手势识别和动画效果。我们将编写平移手势和点击手势的处理函数,并集成一个强大的动画库来创建流畅的交互体验。
平移手势识别器
上一节我们介绍了视图的基本结构,本节中我们来看看如何响应用户的滑动操作。我们将首先编写平移手势识别器的处理函数。
// MARK: - Pan Gesture Recognizer
private func panGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer) {
let translation = gestureRecognizer.translation(in: self)
switch gestureRecognizer.state {
case .began:
// 手势开始时的处理
case .changed:
// 手势变化时的处理
case .ended:
// 手势结束时的处理
default:
// 其他状态的处理
}
}


在.began状态下,我们需要获取用户触摸的初始点,并计算相关的锚点和位置信息,为后续的动画做准备。
以下是.began状态下需要执行的步骤:
initialTouchPoint:获取手势在视图中的初始位置。anchorPoint:计算基于视图边界的归一化锚点。oldPosition:计算图层基于旧锚点的位置。newPosition:计算图层基于新锚点的位置。- 将计算出的新锚点和新位置赋值给图层的
anchorPoint和position属性。 - 设置
layer.shouldRasterize = true以优化动画性能。 - 调用委托方法
didBeginSwipe(on:)通知手势开始。
在.changed状态下,我们需要根据手指移动的距离来计算卡片的旋转和位移。
以下是.changed状态下的核心计算:
rotationStrength:根据横向移动距离与视图宽度的比例计算旋转强度,并限制在最大旋转值内。公式为:min(translation.x / frame.width, SwipeableView.maxRotation)rotationAngle:根据动画方向和旋转强度计算最终的旋转角度。公式为:SwipeableView.animationDirection.y * SwipeableView.rotationAngle * rotationStrengthtransform:创建一个3D变换,先应用旋转,再应用平移。代码为:var transform = CATransform3DIdentity transform = CATransform3DRotate(transform, rotationAngle, 0, 0, 1) transform = CATransform3DTranslate(transform, translation.x, translation.y, 0) layer.transform = transform
在.ended和default状态下,我们将layer.shouldRasterize设置为false,并计划实现动画的结束和重置逻辑。
shouldRasterize属性用于优化图层渲染。当设置为true时,图层会先被渲染成位图再合成,适合复杂动画;设置为false时,图层直接合成。

集成POP动画库


为了实现更自然、有弹性的滑动动画,我们将使用Facebook开源的POP动画库。许多知名应用如Instagram、Messenger都使用了这个库。



首先,我们需要通过CocoaPods将POP库集成到项目中。
以下是集成POP库的步骤:
- 在终端中,使用
cd命令导航到项目根目录。 - 运行
pod init命令创建Podfile文件。 - 打开
Podfile,添加pod ‘pop’, ‘~> 1.0.1’。 - 运行
pod install命令安装依赖库。 - 安装完成后,关闭
.xcodeproj文件,使用新生成的.xcworkspace文件打开项目。


集成成功后,我们可以在代码中导入POP模块并使用它。


实现动画控制函数

现在,我们来实现之前提到的三个动画控制函数:beginAnimation、endedPanAnimation和resetAnimation。
在beginAnimation函数中,我们需要移除图层上所有正在进行的POP动画。
private func beginAnimation() {
layer.pop_removeAllAnimations()
}
在endedPanAnimation函数中,我们需要判断滑动的方向。为此,我们引入了一个SwipeDirection枚举文件和CGPoint的扩展工具类。
SwipeDirection枚举定义了所有可能的滑动方向(如左、右、上、下)。CGPoint扩展提供了一些几何计算方法。


endedPanAnimation函数的核心是计算拖动方向。我们通过归一化的拖动距离,在所有可能的方向中找到距离最近的一个。

private func endedPanAnimation() {
let normalizedDragPoint = panGestureTranslation.normalizedDistance(forSize: bounds.size)
let dragDirection = SwipeDirection.allDirections.reduce((CGFloat.infinity, nil)) { closest, direction in
let distance = direction.point.distance(to: normalizedDragPoint)
return distance < closest.0 ? (distance, direction) : closest
}.1
// 后续将根据dragDirection执行相应动画
}
dragPercentage函数(将在后续实现)用于计算拖动进度百分比,这对于决定卡片是复位还是飞离屏幕至关重要。



本节课中我们一起学习了滑动动画的核心实现。我们编写了平移手势识别器,在began、changed、ended不同状态下处理触摸事件,计算卡片的旋转和位移变换。我们还集成了POP动画库来增强效果,并实现了动画的开始与结束控制函数,为下一节实现卡片的最终滑动行为打下了基础。
004:可滑动卡片模型
在本节课中,我们将学习如何实现一个可滑动卡片的交互模型。我们将重点编写计算拖拽方向、百分比以及处理卡片动画的核心逻辑,包括滑动确认后的动画和滑动中断后的复位动画。
计算拖拽方向与百分比
上一节我们介绍了手势识别的基础。本节中,我们来看看如何根据用户的手势计算拖拽的方向和距离百分比。
首先,我们定义两个私有计算属性。
private var dragDirection: SwipeDirection {
// 此实例来自之前创建的SwipeDirection枚举
// 如果无法确定方向,则返回0.0
return .none
}
private var dragPercentage: CGFloat {
// 初始化返回0.0
return 0.0
}
接下来,我们实现 dragPercentage 的具体逻辑。
private var dragPercentage: CGFloat {
// 1. 获取归一化的拖拽点
let normalizedDragPoint = panGesture.translation(in: self).normalizedDistance(for: frame.size)
// 2. 根据拖拽方向计算预测的滑动点
let swipePoint = normalizedDragPoint.scalarProjection(with: direction.point)
// 3. 定义有效区域
let rect = SwipeDirection.boundsRect
// 检查点是否在有效区域内
if !rect.contains(swipePoint) {
return 1.0
}
// 4. 计算中心点距离并求交点
let centerDistance = swipePoint.distance(to: .zero)
let targetLine = (swipePoint, CGPoint.zero)
// 5. 计算与区域边界的交点,并最终得出百分比
return rect.perimeterLines
.flatMap { CGPoint.intersectionBetweenLines(targetLine, $0) }
.map { centerDistance / $0.distance(to: .zero) }
.min() ?? 0.0
}


代码逻辑说明:
normalizedDragPoint: 获取相对于卡片自身尺寸归一化后的拖拽位移。swipePoint: 将拖拽位移投影到当前滑动方向上,得到一个预测的滑动向量点。- 检查这个预测点是否超出了预设的有效滑动区域(
boundsRect)。如果超出,意味着滑动动作已足够完成,返回1.0(即100%)。 - 如果点在区域内,则计算该点到原点的距离,并构造一条从该点到原点的线段。
- 计算这条线段与有效区域边界线的所有交点。百分比的计算公式为:当前点距原点的距离 / 交点到原点的距离。取所有计算结果中的最小值,确保百分比不会超过1.0。
这个属性用于精确量化用户拖拽卡片完成滑动的进度。
处理滑动结束的动画
在用户结束拖拽手势时,我们需要判断是执行完整的滑动动画,还是将卡片复位。
我们在 handlePanEnded 函数中调用以下逻辑:

private func endedPanAnimation() {
// 获取拖拽方向和百分比
let direction = dragDirection
let percentage = dragPercentage
// 判断是否达到滑动阈值
if percentage >= SwipeableView.swipePercentMargin && direction != .none {
// 执行滑动动画
let translationAnimation = POPBasicAnimation(propertyNamed: kPOPLayerTranslationXY)
translationAnimation.duration = SwipeableView.finalizeSwipeActionAnimationDuration
translationAnimation.fromValue = NSValue(cgPoint: popLayer.translationXY)
translationAnimation.toValue = NSValue(cgPoint: animationPoint(for: direction))
popLayer.pop_add(translationAnimation, forKey: "swipeTranslationAnimation")
// 通知代理滑动已完成
delegate?.didSwipe(on: self, with: direction)
} else {
// 未达到阈值,复位卡片
resetCardViewPosition()
}
}

核心判断条件:percentage >= SwipeableView.swipePercentMargin && direction != .none
这个条件检查拖拽百分比是否达到预设的阈值(例如30%),并且拖拽方向是否有效。如果两者都满足,则触发滑动动画并通知代理;否则,调用复位函数。
其中,animationPoint(for:) 函数用于计算卡片最终应该滑动到的屏幕位置。
private func animationPoint(for direction: SwipeDirection) -> CGPoint {
let point = direction.point
// 将方向向量放大,确保卡片移出屏幕
let animatePoint = CGPoint(x: point.x * 4, y: point.y * 4)
// 将点坐标转换为屏幕坐标系下的点
let screenPoint = animatePoint.screenPoint(for: UIScreen.main.bounds.size)
return screenPoint
}
实现卡片复位功能

如果用户的拖拽未达到滑动阈值,我们需要让卡片平滑地回到初始位置。

以下是复位卡片位置和旋转状态的函数:
private func resetCardViewPosition() {
// 1. 移除所有现有动画
popLayer.pop_removeAllAnimations()
// 2. 复位位置(使用弹性动画)
let resetPositionAnimation = POPSpringAnimation(propertyNamed: kPOPLayerTranslationXY)
resetPositionAnimation.fromValue = NSValue(cgPoint: popLayer.translationXY)
resetPositionAnimation.toValue = NSValue(cgPoint: .zero)
resetPositionAnimation.springBounciness = SwipeableView.cardViewResetSpringBounciness
resetPositionAnimation.springSpeed = SwipeableView.cardViewResetSpringSpeed
resetPositionAnimation.completionBlock = { _, _ in
self.popLayer.transform = CATransform3DIdentity
}
popLayer.pop_add(resetPositionAnimation, forKey: "resetPositionAnimation")
// 3. 复位旋转
let resetRotationAnimation = POPBasicAnimation(propertyNamed: kPOPLayerRotation)
resetRotationAnimation.fromValue = popLayer.getRotationZ()
resetRotationAnimation.toValue = 0.0
resetRotationAnimation.duration = SwipeableView.animationDuration
popLayer.pop_add(resetRotationAnimation, forKey: "resetRotationAnimation")
}
复位过程分解:
- 清理动画:首先移除图层上所有正在进行的POP动画,防止冲突。
- 复位平移:创建一个
POPSpringAnimation弹性动画,将卡片的平移值从当前位置(translationXY)动画变化到原点(.zero)。我们通过springBounciness和springSpeed属性来调整弹性效果。动画完成后,将图层的变换重置为单位矩阵。 - 复位旋转:同时创建一个
POPBasicAnimation基础动画,将卡片的Z轴旋转值复位到0。这确保了卡片在回到中心的同时也摆正了角度。
这个功能模拟了现实世界中,轻轻拨动卡片后它弹回原处的效果。
总结
本节课中我们一起学习了可滑动卡片模型的核心实现。
- 计算交互状态:我们实现了
dragDirection和dragPercentage属性,用于实时计算用户拖拽的方向和进度百分比。 - 处理滑动完成:在
endedPanAnimation方法中,我们根据百分比和方向判断是否触发完整滑动。如果触发,则执行移出屏幕的动画并通知代理。 - 处理滑动取消:如果拖拽未达到阈值,则调用
resetCardViewPosition方法。该方法使用弹性动画和基础动画,将卡片的位置和旋转状态平滑地复位到初始值。

通过这三部分逻辑,我们构建了一个响应灵敏、体验流畅的卡片滑动交互模型。下一节,我们将把这些卡片集成到主视图控制器中。
005:卡片堆栈管理 🃏
在本节课中,我们将学习如何为卡片视图添加手势识别器的动作,并创建一个可滑动的示例卡片。我们将从添加手势动作开始,然后详细设计并实现一个包含图片、标签和按钮的卡片视图。


为手势识别器添加动作
上一节我们介绍了如何添加手势识别器,本节中我们来看看如何为它们关联具体的动作函数。
我们有两个手势识别器需要处理:UIPanGestureRecognizer(滑动手势)和UITapGestureRecognizer(点击手势)。我们已经添加了滑动手势识别器,现在需要为两者创建对应的动作函数。

以下是需要添加的动作函数:

@objc private func handlePanGesture(_ recognizer: UIPanGestureRecognizer) {
// 处理滑动手势的逻辑
}

@objc private func handleTapGesture(_ recognizer: UITapGestureRecognizer) {
// 处理点击手势的逻辑
}

这些函数被标记为 @objc,以便它们可以在 Objective-C 的运行时中被识别和调用,这对于手势识别器的 #selector 语法是必需的。
我们将滑动手势的动作关联到 handlePanGesture 函数,点击手势的动作关联到 handleTapGesture 函数。
在关联过程中,如果遇到“未解析的标识符”错误,例如 animationPointForDirection,请确保相关函数已正确定义。清理项目并重新构建通常可以解决此类问题。
构建时可能会遇到协议一致性错误,例如“SwipeableContainerView does not conform to SwiperDelegate”。这通常意味着需要调整函数签名以符合协议要求。修正后再次构建,直到没有错误。

创建示例卡片视图
手势动作设置完成后,我们现在来创建一个具体的、可滑动的卡片视图。这个卡片将拥有自己的界面布局和对应的代码文件。


我们将创建一个名为 SampleSwipeableCard 的类,它继承自我们基础的可滑动卡片类。同时,我们还会为它创建一个 .xib 文件来设计界面。

以下是创建步骤:
- 新建一个 Swift 文件,定义
SampleSwipeableCard类。 - 新建一个 User Interface 文件,选择“View”模板,将其命名为
SampleSwipeableCard.xib。

设计卡片界面
现在,我们在 .xib 文件中设计卡片的视觉布局。卡片将包含以下元素:
- 一个作为背景的容器视图。
- 一个用于显示用户照片的图片视图。
- 一个显示姓名和职业信息的区域。
- 一个添加好友的按钮。
具体操作如下:
- 在
.xib中,拖入一个 UIView 作为根视图。 - 进入“Size Inspector”,将尺寸模拟设置为“Freeform”,并调整宽高为
335 x 400。 - 确保为该视图启用自动布局约束(Auto Layout)。
- 在根视图中,依次拖入并排列各个子视图(背景容器、图片容器、信息容器、按钮等)。
- 为所有视图添加恰当的约束,确保它们在不同设备尺寸上都能正确显示。
添加约束时需注意:
- 使用“Add New Constraints”按钮为视图添加边距、宽度或高度约束。
- 使用“Ctrl + 拖拽”的方式快速添加居中对齐等约束。
- 如果出现约束冲突(红色警告),需检查并移除重复或不必要的约束。

连接界面与代码
界面设计好后,需要将 .xib 中的界面元素与 SampleSwipeableCard.swift 文件中的代码进行关联。
操作步骤如下:
- 在 Xcode 中同时打开
.xib文件和对应的.swift文件。 - 在
.xib文件中,点击“File‘s Owner”,在“Identity Inspector”中将其类设置为SampleSwipeableCard。 - 使用“Ctrl + 拖拽”的方式,将界面上的元素(如 UILabel、UIButton、UIImageView)拖拽到
.swift文件中,以创建 @IBOutlet 连接。
需要创建的 outlet 包括:
titleLabel:用于显示姓名。subtitleLabel:用于显示职业。addButton:添加按钮。imageBackgroundContainerView:图片背景容器。imageView:用户头像图片视图。
确保所有 outlet 都正确连接,没有出现警告图标。

准备卡片数据模型
为了动态显示卡片内容,我们需要一个数据模型。这个模型将定义卡片所需的数据结构。

我们创建一个名为 SampleSwipeableCardViewModel 的新文件。该模型通常包含以下属性:
title:姓名(String)。subtitle:职业(String)。color:背景色(UIColor)。imageName:图片名称(String)。
模型的定义类似于:
struct SampleSwipeableCardViewModel {
let title: String
let subtitle: String
let color: UIColor
let imageName: String
}
之后,我们可以在 SampleSwipeableCard 类中添加一个配置方法,用于接收 ViewModel 并更新界面:
func configure(with viewModel: SampleSwipeableCardViewModel) {
titleLabel.text = viewModel.title
subtitleLabel.text = viewModel.subtitle
backgroundContainerView.backgroundColor = viewModel.color
imageView.image = UIImage(named: viewModel.imageName)
}
集成到容器中
最后,我们需要将这个创建好的 SampleSwipeableCard 实例添加到主视图控制器中的卡片堆栈容器里。
在主视图控制器的代码中(例如 viewDidLoad 方法中):
- 实例化
SampleSwipeableCard(通常从.xib加载)。 - 创建一个对应的
SampleSwipeableCardViewModel数据对象。 - 调用卡片的
configure(with:)方法传入数据。 - 将卡片添加到负责管理堆栈和滑动的容器视图中。
这样,一个完整的、带有数据和交互功能的可滑动卡片就集成到了我们的应用中。



本节课中我们一起学习了如何为手势识别器添加动作、设计并实现一个复杂的卡片视图、连接界面与代码、创建数据模型,以及最终将卡片集成到应用的主流程中。这些步骤是构建滑动匹配应用核心交互界面的基础。
006:自定义可滑动单元格结构 🏗️


在本节课中,我们将学习如何创建一个自定义的可滑动卡片视图。我们将定义一个数据模型来管理卡片的内容,并配置卡片的视觉样式,包括圆角和阴影效果。
概述

我们将创建一个名为 SampleSwipeableCellViewModel 的数据模型,它定义了卡片上显示的内容。然后,我们将在 SampleSwipeableCard 视图中使用这个模型,并配置其外观,包括背景色、图片、文本以及添加阴影效果。

创建数据模型
首先,我们需要定义一个结构体来作为卡片视图的数据模型。这个模型将包含标题、副标题、背景颜色和图片。
以下是 SampleSwipeableCellViewModel 结构体的定义:
struct SampleSwipeableCellViewModel {
let title: String
let subtitle: String
let color: UIColor
let image: UIImage
}

这个模型包含了四个属性:title(标题)、subtitle(副标题)、color(背景颜色)和 image(图片)。
在卡片视图中配置模型

上一节我们创建了数据模型,本节中我们来看看如何在 SampleSwipeableCard 视图中使用它。
我们将在卡片视图中添加一个 viewModel 属性。当这个属性被设置时,我们会调用一个 configure 方法来更新界面。
在 SampleSwipeableCard 类中,添加以下代码:
var viewModel: SampleSwipeableCellViewModel? {
didSet {
configure(for: viewModel)
}
}

private func configure(for viewModel: SampleSwipeableCellViewModel?) {
guard let viewModel = viewModel else { return }
titleLabel.text = viewModel.title
subtitleLabel.text = viewModel.subtitle
imageBackgroundColorView.backgroundColor = viewModel.color
imageView.image = viewModel.image
}

configure 方法的作用是将数据模型中的值赋给对应的界面元素。我们使用 guard let 语句来安全地解包可选的 viewModel。

设置视图样式
配置好基本内容后,我们需要为卡片设置视觉样式,使其看起来更美观。
以下是设置圆角的方法:
private func setupViewStyle() {
// 设置卡片本身的圆角
layer.cornerRadius = 15.0
// 设置按钮的圆角,使其高度的一半
addButton.layer.cornerRadius = addButton.frame.size.height / 4
}
我们在视图初始化或布局更新时调用此方法,以确保圆角效果正确应用。
添加阴影效果

为了让卡片有立体感,我们接下来为其添加阴影效果。这将在 layoutSubviews 方法中完成。
以下是配置阴影的步骤:
- 移除可能已存在的旧阴影视图。
- 创建一个新的视图作为阴影层。
- 设置阴影层的位置和大小,使其略大于卡片主体,以产生偏移效果。
- 将阴影层插入为卡片的最底层子视图。
- 应用阴影属性,如颜色、透明度、偏移量和路径。

override func layoutSubviews() {
super.layoutSubviews()
configureShadow()
}
private func configureShadow() {
// 1. 移除旧的阴影视图
shadowView?.removeFromSuperview()
// 2. 创建新的阴影视图
let shadowView = UIView(frame: CGRect(x: 20, y: 20, width: bounds.width - 40, height: bounds.height - 40))
shadowView.backgroundColor = .clear
// 3. & 4. 插入阴影视图
insertSubview(shadowView, at: 0)
self.shadowView = shadowView
// 5. 应用阴影属性
applyShadow(to: shadowView)
}
private func applyShadow(to view: UIView) {
view.layer.masksToBounds = false
view.layer.cornerRadius = 8.0
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOffset = CGSize(width: 0, height: 0)
view.layer.shadowOpacity = 0.15
let shadowPath = UIBezierPath(roundedRect: view.bounds, cornerRadius: 14.0)
view.layer.shadowPath = shadowPath.cgPath
}
源代码控制
在开发过程中,及时提交代码是一个好习惯。我们可以使用Xcode内置的源代码控制功能。
以下是提交更改的步骤:
- 在项目导航器中,选择已修改的文件。
- 在源代码控制菜单中选择“Commit...”。
- 在提交窗口中,勾选要提交的文件,并输入有意义的提交信息,例如“步骤2:准备样本卡片并添加阴影和模型”。
- 点击“Commit”按钮提交更改。
提交后,你可以在“Source Control Navigator”中查看提交历史,了解项目的演变过程。
总结

本节课中我们一起学习了如何构建滑动匹配应用的自定义卡片组件。我们首先定义了一个数据模型 SampleSwipeableCellViewModel 来封装卡片内容。然后,我们在 SampleSwipeableCard 视图中集成该模型,并通过 configure 方法动态更新UI。接着,我们美化了卡片视图,为其添加了圆角和复杂的阴影效果,以提升视觉层次感。最后,我们简要介绍了如何使用Xcode的源代码控制功能来管理代码版本。通过这些步骤,我们完成了一个具有良好数据结构和视觉表现的可滑动卡片单元。
007:匹配屏幕用户界面

在本节课中,我们将学习如何为匹配屏幕构建一个带有阴影效果的静态头部视图。我们将创建一个自定义的UIView子类,并通过XIB文件来设计其布局,最后通过代码为其添加圆角和阴影效果。
概述
上一节我们完成了卡片视图的基础搭建。本节中,我们将创建一个位于屏幕顶部的静态头部视图。这个视图将包含背景容器、文本标签和一个指示图标,并会为其添加美观的圆角和阴影效果。

添加辅助文件


首先,我们需要一个辅助文件来帮助我们从XIB文件加载视图。这个文件是一个UIView的扩展,它封装了从XIB初始化视图并设置约束的通用逻辑。
以下是该扩展的核心代码:
extension UIView {
func xibSetup() {
backgroundColor = .clear
let view = loadViewFromNib()
addEdgeConstraints(view: view)
}
private func loadViewFromNib() -> UIView {
let bundle = Bundle(for: type(of: self))
let nib = UINib(nibName: String(describing: type(of: self)), bundle: bundle)
let view = nib.instantiate(withOwner: self, options: nil).first as! UIView
return view
}
private func addEdgeConstraints(view: UIView) {
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: topAnchor),
view.bottomAnchor.constraint(equalTo: bottomAnchor),
view.leadingAnchor.constraint(equalTo: leadingAnchor),
view.trailingAnchor.constraint(equalTo: trailingAnchor)
])
}
}
在自定义视图的初始化方法中调用 xibSetup() 即可完成视图的加载和约束配置。


创建静态头部视图


接下来,我们创建头部视图本身。这需要创建一个Swift类文件和一个对应的XIB界面文件。

- 新建一个Swift文件,命名为
StaticShadowHeaderView。 - 新建一个User Interface文件,选择View模板,同样命名为
StaticShadowHeaderView.xib。

设计XIB界面

打开 StaticShadowHeaderView.xib 文件,开始设计界面布局。
以下是需要添加到视图中的组件及其配置步骤:

- 主背景容器:添加一个UIView,将其尺寸设置为自由格式,宽度382,高度80。将其重命名为
backgroundContainerView以避免混淆。为其设置圆角半径为14,并选择一个默认背景色。 - 内部容器:在背景容器内添加另一个UIView,设置其约束为:上、下、左、右各距离背景容器15点。这个视图将作为内容区域。
- 标题标签:在内部容器中添加一个UILabel。设置其字体为24pt的粗体,例如“Saturday 29 September”。为其添加约束:距离内部容器左、右各20点,顶部0点,底部5点。
- 副标题标签:添加第二个UILabel,用于显示“Last seen yesterday”之类的文本。设置其字体颜色为浅灰色。约束设置与标题标签类似。
- 指示图标:添加一个UIImageView来显示一个箭头(Chevron)图标。首先需要将图标资源(Chevron图片)拖入项目的Assets.xcassets资源目录中。然后在XIB中设置该UIImageView的图片为该资源,并添加约束:宽度20,高度20,距离父视图右边缘25点,并在垂直方向上居中对齐。

完成后的界面应包含上述所有元素,并且约束设置完整,没有缺失或冲突的警告。

编写视图代码

现在,将XIB文件的File‘s Owner类设置为 StaticShadowHeaderView,并开始编写对应的Swift类代码。
首先,建立IBOutlet连接,将 backgroundContainerView 连接到代码中。
class StaticShadowHeaderView: UIView {
@IBOutlet weak var backgroundContainerView: UIView!
}
然后,在类中实现必要的属性和方法:
- 初始化方法:在
init?(coder:)和init(frame:)方法中调用xibSetup()来加载XIB布局。 - 设置圆角:在初始化后,设置
backgroundContainerView.layer.cornerRadius = 14。 - 配置阴影:我们不在背景容器上直接加阴影,而是创建一个专门的
shadowView来模拟阴影效果,这样可以获得更好的性能和控制。在layoutSubviews()方法中调用一个私有方法来配置这个阴影视图。 - 阴影方法:创建一个
configureShadow()方法。该方法会:- 移除已存在的旧阴影视图。
- 创建一个新的UIView作为
shadowView,其尺寸略大于背景容器。 - 将其插入到视图层的最底部(index 0)。
- 设置该视图的
shadowPath、shadowRadius、shadowColor、shadowOffset和shadowOpacity来生成阴影效果。
核心的阴影配置代码如下:
private func configureShadow() {
shadowView?.removeFromSuperview()
let shadowView = UIView(frame: CGRect(x: innerMargin, y: innerMargin, width: bounds.width - 2*innerMargin, height: bounds.height - 2*innerMargin))
insertSubview(shadowView, at: 0)
self.shadowView = shadowView
shadowView.layer.shadowPath = UIBezierPath(roundedRect: shadowView.bounds, cornerRadius: 14).cgPath
shadowView.layer.masksToBounds = false
shadowView.layer.shadowRadius = 14
shadowView.layer.shadowColor = UIColor.black.cgColor
shadowView.layer.shadowOffset = CGSize(width: 0, height: 0)
shadowView.layer.shadowOpacity = 0.15
}
通过以上步骤,我们就创建了一个具有自定义圆角和柔和阴影的精致头部视图组件。

总结



本节课中我们一起学习了如何构建匹配屏幕的静态头部用户界面。我们首先利用一个辅助扩展从XIB文件加载视图,然后详细设计了包含背景、文字和图标布局的XIB文件,最后通过编写Swift代码,为视图添加了圆角并实现了一个高性能的阴影效果,使UI看起来更加立体和美观。这个可复用的组件为应用顶部区域提供了标准的视觉样式。
008:导航转换 🧭



在本节课中,我们将学习如何将之前创建的各个组件文件整合到主视图控制器中,并设置卡片容器的数据源和委托,以实现完整的滑动匹配功能。
概述


上一节我们创建了可滑动的卡片视图。本节中,我们将在主故事板中搭建界面,将 SwipeableCardViewContainer 容器与 ViewController 连接起来,并实现必要的数据源协议,让卡片能够显示和交互。

在故事板中搭建界面
首先,我们需要打开主故事板文件,并在初始视图控制器(即应用启动时显示的屏幕)上添加界面元素。
以下是需要添加的组件步骤:

-
添加顶部静态视图:这是一个作为背景或标题区域的视图。
- 将其
Frame设置为:x: 5, y: 64, width: 365, height: 110。 - 确保它被添加到代表首页的
View Controller中,而非其他标签页。
- 将其
-
添加可滑动卡片容器视图:这是用于承载多层卡片的主要视图。
- 将其
Frame设置为:x: 5, y: 186, width: 365, height: 400。
- 将其

- 添加提示标签:当所有卡片都被滑动完毕后,需要显示提示信息。
- 添加一个
UILabel,将其Frame设置为:x: 135, y: 376, width: 106, height: 21。 - 设置文本为
All caught up(意为“已无更多卡片”),颜色设为浅灰色。 - 注意视图的层级关系:后添加的视图会覆盖在先添加的视图之上。因此,卡片容器视图会覆盖这个标签,在初始状态下标签不可见是正常的。
- 添加一个
设置自动布局约束

为了确保界面在不同尺寸的设备上都能正确显示,我们需要为各个视图添加约束。


以下是约束设置步骤:
-
为顶部静态视图(重命名为
StaticHeaderView)添加约束:- 左、右、上边距均为
5点,下边距为20点。 - 固定高度为
110点。
- 左、右、上边距均为
-
为可滑动卡片视图(重命名为
SwipeableCardViewContainer)添加约束:- 左、右边距均为
5点。 - 高度固定为
400点。 - 在安全区域内 水平居中 和 垂直居中。
- 其顶部与
StaticHeaderView底部的空间约束设置为“大于等于”某个值。
- 左、右边距均为
-
为“All caught up”标签添加约束:
- 在安全区域内 水平居中 和 垂直居中。

添加约束后,如果出现黄色警告线(表示帧位置与约束不符),可以选中视图,点击“Update Frames”来根据约束重新定位。如果存在红色冲突线,需要检查并调整约束,例如将某个固定边距改为灵活范围。

连接出口与属性
现在,我们需要在代码中建立与故事板中视图的连接。
-
设置视图控制器背景色:在故事板中,选择
View Controller的主视图,将其背景色从默认的黑色改为更合适的颜色,例如“Group Table View Background Color”。 -
创建出口连接:
- 打开
ViewController.swift文件。 - 为
SwipeableCardViewContainer创建一个出口属性:@IBOutlet private weak var swipeableCardViewContainer: SwipeableCardViewContainer! - 由于我们确定该视图一定存在,可以使用隐式解包可选类型
SwipeableCardViewContainer!。 - 回到故事板,选中
SwipeableCardViewContainer视图,在“Identity Inspector”中将其类由UIView改为SwipeableCardViewContainer。 - 最后,按住
Ctrl键从该视图拖拽到ViewController代码中的@IBOutlet行,完成连接。
- 打开
实现卡片容器数据源
SwipeableCardViewContainer 需要一个数据源来提供卡片内容和数量。我们需要在 SwipeableCardViewContainer.swift 文件中实现其核心逻辑。


以下是 SwipeableCardViewContainer 的核心实现步骤:
-
定义协议与属性:
- 定义
SwipeableCardViewDataSource和SwipeableCardViewDelegate协议(后者可先注释以避免编译错误)。 - 声明
dataSource和delegate属性。 - 声明存储卡片的数组
cardViews。 - 定义属性
remainingCards来跟踪剩余未显示卡片数。 - 定义常量
numberOfVisibleCards(例如3)来设置同时可见的最大卡片数。 - 定义常量
horizontalInset和verticalInset(例如12.0)来设置卡片间的重叠偏移量。
- 定义
-
初始化设置:在
awakeFromNib()方法中:- 将容器背景色设为
.clear。 - 设置
translatesAutoresizingMaskIntoConstraints = false,因为我们使用自动布局约束。
- 将容器背景色设为


- 重载数据方法
reloadData():- 首先,移除所有现有的卡片视图。
- 从
dataSource获取总卡片数,并赋值给remainingCards。 - 循环创建卡片(循环次数取
总卡片数和numberOfVisibleCards中的较小值)。 - 在每次循环中,调用
addCardView(_:at:)方法添加新卡片。

- 添加卡片方法
addCardView(_:at:):- 设置卡片的
delegate为self(即容器自身)。 - 调用
setFrame(for:at:)方法根据索引设置卡片的位置和大小,实现层叠效果。 - 将卡片视图添加到
cardViews数组并插入到容器视图的底层。
- 设置卡片的

-
设置卡片帧方法
setFrame(for:at:):- 计算卡片的
frame。其宽度和高度根据容器bounds和inset值计算。 - 卡片的
origin.x和origin.y会根据其索引乘以inset值进行偏移,从而实现层叠视觉效果。cardViewFrame.origin.x += CGFloat(index) * horizontalInset cardViewFrame.origin.y += CGFloat(index) * verticalInset cardViewFrame.size.width -= 2 * CGFloat(index) * horizontalInset
- 计算卡片的
-
实现滑动手势委托:
- 在
didSelectCard(at:)中通知代理某张卡片被点击。 - 在
didEndSwipingCard(at:)中处理滑动结束的逻辑:- 从视图层级和
cardViews数组中移除被滑走的卡片。 - 如果还有剩余卡片(
remainingCards > 0),则通过数据源获取下一张新卡片并添加。 - 为所有当前可见的剩余卡片触发一个动画,重新计算并设置它们的新位置(索引减一),模拟卡片向上推进的效果。
- 从视图层级和
- 在
在视图控制器中集成
最后,我们需要在 ViewController 中设置数据源并触发数据加载。
- 设置数据源:在
ViewController的viewDidLoad方法或适当的位置,将卡片容器的数据源设为self:swipeableCardViewContainer.dataSource = self - 实现数据源协议:让
ViewController遵循SwipeableCardViewDataSource协议,并实现其必需的方法,例如numberOfCards()和card(forItemAt:),以提供实际的数据模型给卡片容器。


总结


本节课中我们一起学习了导航与界面集成的关键步骤。我们首先在主故事板中搭建了应用的静态界面,并设置了完整的自动布局约束。然后,我们将自定义的 SwipeableCardViewContainer 与视图控制器连接,并深入实现了其内部的数据管理、卡片布局和滑动交互逻辑。通过实现数据源协议,我们为卡片容器注入了动态数据的能力。至此,一个具有可视化层叠卡片和流畅滑动交互功能的核心界面已经构建完成。
009:最终用户界面润色 🎨
在本节课中,我们将对滑动匹配应用的用户界面进行最后的润色和调试。我们将解决之前遇到的错误,配置数据源,并确保卡片视图能够正确显示和交互。
解决错误并配置数据源
上一节我们完成了卡片视图的布局,但在运行时遇到了错误。本节中,我们来看看如何解决这个问题并配置数据源。
首先,我们需要在视图控制器的扩展中添加数据源方法。以下是需要实现的方法:
numberOfCards: 返回卡片的总数。card(forItemAt:): 根据索引返回对应的卡片视图。emptyView(): 当没有更多卡片时返回一个空视图。
在实现数据源之前,我们需要创建一些静态的用户数据模型。


创建静态用户数据模型
为了测试应用,我们将创建一组模拟用户数据。以下是创建这些模型的步骤:
- 在视图控制器的扩展中,定义一个包含多个
SampleSwipeableCardViewModel实例的数组。 - 每个模型实例代表一个用户,包含姓名、职业、背景色和头像图片名称。
以下是创建用户模型的代码示例:
let sarish = SampleSwipeableCardViewModel(title: "Sarish Raheja",
subtitle: "Fitness Trainer",
color: UIColor(red: 0.98, green: 0.81, blue: 0.46, alpha: 1.0),
imageName: "sarish_image")
let george = SampleSwipeableCardViewModel(title: "George Christ",
subtitle: "Travel Business",
color: UIColor(red: 0.29, green: 0.64, blue: 0.96, alpha: 1.0),
imageName: "george_image")
// ... 以此类推创建更多用户
let viewModels = [sarish, george, priya, kagel, guitar, sundip]
在创建模型之前,请确保已将所需的图片资源(如 sarish_image, george_image 等)添加到项目的 Assets.xcassets 目录中。

实现数据源方法
现在,我们可以使用上面创建的 viewModels 数组来实现数据源方法。
- 在
numberOfCards方法中,返回viewModels.count。 - 在
card(forItemAt index:)方法中,根据索引获取对应的视图模型,并将其赋值给一个新的SampleSwipeableCard实例,然后返回该卡片视图。 - 在
emptyView()方法中,目前可以暂时返回nil。
核心实现代码如下:
func numberOfCards() -> Int {
return viewModels.count
}

func card(forItemAt index: Int) -> SwipeableCardView {
let viewModel = viewModels[index]
let cardView = SampleSwipeableCard()
cardView.viewModel = viewModel
return cardView
}

func emptyView() -> UIView? {
return nil
}


检查界面连接与调试
实现数据源后,运行应用可能仍然无法正常显示。以下是常见的检查与调试步骤:

- 检查Storyboard连接:确保
SampleSwipeableCard在Storyboard中的自定义类已正确设置,并且所有@IBOutlet(如背景容器、图片视图、标题标签等)都已正确连接到文件所有者。 - 修复循环引用导致的崩溃:如果应用崩溃并提示内存问题,可能是由于在
reloadData方法中出现了无限循环。检查更新剩余卡片数量的逻辑,确保使用了正确的递减运算符-=。
错误示例:remainingCards = -1
正确示例:remainingCards -= 1 - 检查静态头部视图:确保应用顶部的静态信息栏(如显示“Tinder”Logo和图标的部分)对应的视图类已正确设置,并且其内部的子视图连接无误。

完成这些检查和修复后,再次运行应用。此时应该能够看到所有用户卡片以堆叠形式显示,并且可以通过左右滑动来表示“喜欢”或“不喜欢”。
功能回顾与扩展

本节课中我们一起学习了如何为滑动匹配应用注入数据并完善界面。
我们已经成功实现了Tinder应用的核心交互逻辑:通过左右滑动手势来评价卡片。目前应用使用的是本地静态数据,在实际开发中,这些用户数据通常会从服务器接口获取。

你可以在此基础上进一步扩展应用功能,例如:
- 在滑动后添加“已添加至收藏”或“已跳过”等提示信息。
- 实现“刷新”或“撤销”按钮来重新加载或回退操作。
- 集成即时聊天、个人资料详情页等更多功能模块。



通过本课程,你已经掌握了使用Swift创建滑动匹配式应用界面的基本方法,可以在此基础上构建功能更丰富的社交应用。


本节总结:本节课程完成了应用界面的最后润色。我们解决了数据源错误,配置了模拟用户数据,修复了界面连接问题,并确保了核心的滑动匹配功能正常运行。现在,一个基础版本的滑动匹配应用已经可以工作了。

浙公网安备 33010602011771号