接上一节课理论问题,本课将针对多线程应用部分演示,通过从safari浏览器拖拽图片到画布并下载到本地。
上一课的代码里我们在接受放下类型里已加入了.url与.image,所以我们可以尝试从safari拖拽进入,下面需要对进入的数据进行处理能才正确的使用,我们重写func drop增加对URL与imageData的数据处理:
1、修改放下函数
//当被接受的内容[.plainText,.url,.image]放下后执行的函数
private func drop(providers: [NSItemProvider], at location: CGPoint, in geometry: GeometryProxy) -> Bool {
//先从URL检测开始并尝试放下然后抓取远程图片并设置为背影
var found = providers.loadObjects(ofType: URL.self) { url in
//url.imageURL来算扩展extension URL,将检测URL是否正确
document.setBackground(.url(url.imageURL))//VM里的设置背影
}
//当found返回false时尝试放下图片
if !found {
found = providers.loadObjects(ofType: UIImage.self) { image in
//尝试将image返回包含指定JPEG格式图像的数据对象。
if let data = image.jpegData(compressionQuality: 1.0) {
document.setBackground(.imageData(data))//imageData枚举值
}
}
}
//最后尝试放下Emoji,这也是上一课已实现的功能
if !found {
found = providers.loadObjects(ofType: String.self) { string in
//isEmoji通过算法判断是否为Emoji
if let emoji = string.first, emoji.isEmoji {
//调用VM里的addEmoji
document.addEmoji(
String(emoji),
at: convertToEmojiCoordinates(location, in: geometry),
size: defaultEmojiFontSize
)
}
}
}
return found
}
2、尝试在画板上显示背景
原来的背影是Color.yellow,我们需要将这代码替换成下面的:
Color.white.overlay(
//使用背影图片覆盖,图片来源VM
Image(uiImage: document.backgroundImage)
//将背影图片定位到画布中间
.position(convertFromEmojiCoordinates(0,0, in: geometry))
)
我们通过使用一个UIImage覆盖白色背影的方式来实现背景的设置,但是URL远程下载的方法我们还没有写。
3、定义document.backgroundImage:
@Published var backgroundImage: UIImage?//定义背影图片
因为图片可能不存在,所以我们只能定义成Optional类型,但是Image不能直接使用Optional所以我们对其进行了修改为OptionalImage(来源于上一课的UtilityViews.swift)
4、触发.bakgroundImage更新
上面我们只是定义了 bakgroundImage 但要更新其值,但需要与Model的数据产生联动,所以我们为emojiArt设置一个观察者属性更新bakgroundImage的值:
@Published private(set) var emojiArt: EmojiArtModel {
didSet {
//如果background的值产生了变化则执行抓取任务
if emojiArt.background != oldValue.background {
fetchBackgroundImageDataIfNecessary()
}
}
}
上面我们使用了 mojiArt.background != oldValue.background 来判断两个背景值是否相等,会报错:Binary operator '!=' cannot be applied to two 'EmojiArtModel.Background' operands,我们只需要在枚举里增加:Equatable协议即可,swift会自动完成==与!=的工作。
5、实现 fetchBackgroundImageDataIfNecessary() 远程抓取图片(14:00):
//定义一下远程图片背景的抓取状态,默认为.idle
//其目的是为了让用户在视图上知道现在我们正在抓取内容
@Published var backgroundImageFetchStatus = BackgroundImageFetchStatus.idle
//定义状态的枚举选项
enum BackgroundImageFetchStatus {
case idle
case fetching
}
//实际远程抓取函数
private func fetchBackgroundImageDataIfNecessary() {
backgroundImage = nil//假如上一次抓取还未完成,则立即初始化重新来过
switch emojiArt.background {
// 检测到是URL开始尝试抓取工作
case .url(let url):
backgroundImageFetchStatus = .fetching //将状态设置为抓取中
//使用后台队列的userInitiated(上一课结尾理论有讲)优先等级异步处理
DispatchQueue.global(qos: .userInitiated).async {
let imageData = try? Data(contentsOf: url)//使用Data完成远程下载工作(此步耗时)
DispatchQueue.main.async { [weak self] in //当抓取完成后切换到主队列异步
//判断被下载的地址与用户最新发新的地址是否一致(用户可能会放很多进来)
if self?.emojiArt.background == EmojiArtModel.Background.url(url) {
self?.backgroundImageFetchStatus = .idle//更新抓取状态(弱引用)
//上面使用了try?说明imageData是有可能失败的
if imageData != nil {
//图片抓取成功后将其保存到backgroundImage变量(使用UIImage转换数据)
self?.backgroundImage = UIImage(data: imageData!)//self?为弱引用
}
}
}
}
//检测到是Data数据
case .imageData(let data):
backgroundImage = UIImage(data: data)
//未设置背影的情况
case .blank:
break
}
}
此步代码虽多,但是上一节里的理论部分已详细对后台队列和主队列进行讲解,这里的知识点是 [weak self] 的理解(视频21:40处)。weak self是一个弱引用,其目的为了使被执行完后的闭包引用计数清0以达到内存回收的目的。self?.backgroundImage里的self变成了Optional类型,如果没有人将其保存到堆里的时候,则会变成nil值被系统自动回收。
6、显示图片下载信息(提升用户体验)
在上面的ViewModel里我们使用了backgroundImageFetchStatus获取到了图片抓取状态。通过判断当前状态我们可以在视频里做一些提供用户体验的事情:
//backgroundImageFetchStatus 是一个 @Published var
if document.backgroundImageFetchStatus == .fetching{
ProgressView().scaleEffect(2)
} else{ ... }
手势都是关于用户触控系统获取到的输入信息。swift拥有强大的系统监控用户手指做出的手势。我们的能做的就是处理用户的手势输入后需要完成的事情。
1、在视图里应用手势
MyView.gesture(theGesture)//组合手势需要定义成theGesture通过.gesture附加在视图上
2、创建离散手势
//some Gesture可以返回组合手势
var theGesture: some Gesture{
return TapGesture(count:2)//双击手势
.onEnded { /* 监听到用户双击后要完成的事务 */ }
}
上面创建了手势监听2次点击,监听到已后我们就要运行用户动作对应的代码。这样的手势动作定义起来非常简单。系统已自带了这些手势:
MyView.onTapGesture(count: Int) { /* 双击后要执行的代码 */ }
MyView.onLongPressGesture(...) { /* 长按后要执行的代码 */ }
所以基本上也不会去定义这类离散手势。我们更多时候使用的是非离散手势的定义。
3、非离散手势
在此手势中,不仅要处理被手势识别并结束的事务(捏合结束了),也可能想要在捏全过程中数据产生的一些变化,如根据捏调整大小、拖动离开位置、旋转等。上面写到的LongPressGesture可以是离散的,也可以说是非离散的,当按下时可以执行一些动画或者其它操作,松开已经也可以处理其它结果(长按点赞数量一直增加,松开就停止)。
在非离散手势的.onEnded里可以获取到value in的值:
var theGesture: some Gesture{
DragGesture( ... )
.onEnded { value in /* 在执行的代码中可以访问 value */}
}
其value值来源于当前所监听的手势。参考下面的分类:
a. DragGesture 拖动手势 其value值可以是开始与结束拖动的位置。
b.MagnificationGesture 放大捏合手势 其value值 返回 手指移动了多大、多远。
c.RotationGesture 旋转手势其value值 为角度。
关于手势的分类与介绍:http://www.55mx.com/ios/133.html
4、非离散手势的过程监听
非离散手势还有一个.updating可以监听手势的执行过程,我们需要定义一个@GestureState var配合使用:
@GestureState var myGestureState: MyGestureStateType = <你的类型值>
与@State非常相似,可以存放你想要的任何类型,但这是.updating手势更新中专用状态,随着手势不同,此值会实时更新,当完成了手势更新刚此值回到初始值状态。
var theGesture: some Gesture{
DragGesture( ... )
.updating($myGestureState) { value, myGestureState, transaction in
myGestureState = value //myGestureState 传入的目的就是可以被修改的,实际是$myGestureState绑定的代理变量
}
}
视频第39分左右,对上面3个参数进行了讲解,这是本课中需要理解的重点内容,$myGestureState 是告诉系统你使用的哪个@GestureState,闭包里有3个参数,随着手指的移动和手势的进展,闭包被重复的调用 。闭包里的value参数与onEnded里的参数相同(取决于手势),第二个参数 myGestureState 是一个输入输出的参数(我们通过修改此参数值以达到更新被@GestureState修饰的变量,所以这个命名可以随意的,这只是一个被绑定参数$myGestureState的别名而以),可以修改自己的@GestureState修饰的变量,否则@GestureState只是一个只读的参数。第三个transaction 是与动画有关的,这需要查阅其它资料,视频里没有讲到,并且在后续的学习中也未使用过。
除了上面的.onEnded与.updating,swiftUI还提供一个.onChanged的监听:
var theGesture: some Gesture{
DragGesture( ... )
.onChanged{ value in //这里只提供value
/* 根据手势并通过value参数处理事务 */
}
}
1、定义适合屏幕大小的函数:
现在拖入到画布的图片有大有小,我们希望有一个双击手势,让背影自动调整到合适屏幕的尺寸。首先我们要定义一个可变的缩放比例值:
@State private var zoomScale: CGFloat = 1 //默认缩放比例(配合成为.scaleEffect的参数)
//通过计算返回图片被缩放的比例
private func zoomToFit(_ image: UIImage?, in size: CGSize) {
//let image = image,后面的image是来算参数let image: UIImage?,判断图片的和容器值需要都大于0
if let image = image, image.size.width > 0, image.size.height > 0, size.width > 0, size.height > 0 {
let hZoom = size.width / image.size.width //返回宽的比例值
let vZoom = size.height / image.size.height//返回高的比例值
zoomScale = min(hZoom, vZoom)//修改zoomScale选择一个更小的值
}
}
2、在背景和表情上应用缩放效果:
OptionalImage(uiImage: document.backgroundImage)
.scaleEffect(zoomScale)//增加背影图片缩放
表情同步缩放:
Text(emoji.text)
.scaleEffect(zoomScale)//表情缩放
3、处理被缩放后坐标偏移的问题:
当我们的图被缩放后,需要对其坐点更新以达到从中心点缩放的目的:
//反转坐标为Int元组值
private func convertToEmojiCoordinates(_ location: CGPoint, in geometry: GeometryProxy) -> (x: Int, y: Int) {
let center = geometry.frame(in: .local).center
let location = CGPoint(
x: (location.x - center.x) * zoomScale,//将x结果 * 缩放比例
y: (location.y - center.y) * zoomScale//将y结果 * 缩放比例
)
return (Int(location.x), Int(location.y))
}
//将表情坐标转换成CGPoint值
private func convertFromEmojiCoordinates(_ location: (x: Int, y: Int), in geometry: GeometryProxy) -> CGPoint {
let center = geometry.frame(in: .local).center
return CGPoint(
x: center.x + CGFloat(location.x) * zoomScale, //坐标x以中心x * 缩放 出发
y: center.y + CGFloat(location.y) * zoomScale //坐标y以中心y * 缩放 出发
)
}
4、让拖入的表情也自动缩放
document.addEmoji(
String(emoji),
at: convertToEmojiCoordinates(location, in: geometry),
size: defaultEmojiFontSize / zoomScale //向画布拖入后自动缩放
)
5、增加双击自动缩放的手势
我们先在Color.white的背影上增加一个应用自定义手势,传入当然可用空间的尺寸。
Color.white.overlay(...)
.gesture(doubleTapToZoom(in: geometry.size))//应用自己定义的doubleTapToZoom手势
实现手势 doubleTapToZoom,当双击完成后我们配合动画并通过zoomToFit函数来修改zoomScale的值,然后通过scaleEffect实现缩放,因为zoomScale值在上面的算法处也有修改也会导致其它参数的联动修改:
//定义一个手势,传入geometry.size的值
private func doubleTapToZoom(in size: CGSize) -> some Gesture {
TapGesture(count: 2)
.onEnded {
withAnimation {
//应用动画通过zoomToFit修改zoomScale的值,然后通过scaleEffect实现缩放
zoomToFit(document.backgroundImage, in: size)
}
}
}
在func zoomToFit里,我们限制了必须 if let image = image才可以进行一个缩放功能,所以当没有背影图片的时候双击是看不到任何的效果的。
6、修改图片越界问题:
当我们的背影图片过大的时候会越界,所以我们将画布可用范围进行限制。我们在docmentBody上增加一个:
.clipped() //将此视图剪辑到其边界矩形框架中。
超过本视图的内容不会越到下一个视图上去,自动的被隐藏掉了。
在上面我们已完全了当双击背影图片将自动将图片缩放到画布大小,现在我们需要通过非离散手势的捏合手势完成缩放微调操作。
1、应用捏合手势:
我们需要在整个画板上使用捏合手势,放入下面的代码:
.gesture(zoomGesture())//应用zoomGesture()手势
2、实现zoomGesture()
//捏合手势修改zoomScale的值以达到缩放效果
private func zoomGesture() -> some Gesture {
MagnificationGesture()//捏合手势(非离散手势)
.onEnded { gestureScaleAtEnd in
zoomScale *= gestureScaleAtEnd //修改zoomScale的值(*=)
}
}
现在我们可以通过按住Opt键模拟捏合手势效果。结果发现能缩放但效果很僵硬,就算应用动画也只是手指松开后慢慢的播放动画。所以我们需要使用到上面学到的@GestureState 来解决这个问题。
3、让捏合实时更新
首先我们要定义一个可以监听手势更新状态的值,用于跟踪状态的实时情况:
@GestureState private var gestureZoomScale: CGFloat = 1
然后修改还需要将原来的zoomScale修改为steadyStateZoomScale而zoomScale则变成了一个计算属性:
@State private var steadyStateZoomScale: CGFloat = 1 //函数将要修改的值
@GestureState private var gestureZoomScale: CGFloat = 1 //通过手势实现更新的值
private var zoomScale: CGFloat {
steadyStateZoomScale * gestureZoomScale //缩放比例根据2个值更新
}
记得将zoomToFit与zoomGesture里的值也更新为steadyStateZoomScale(视频1:05:30处有详细说明),重点理解zoomScale的计算属性算法。
4、steadyStateZoomScale实时更新(1:06:29)
经过上面的修正参数后,我们将为zoomGesture增加一个.updating监听实时更新的值。
private func zoomGesture() -> some Gesture {
MagnificationGesture()
.updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in
gestureZoomScale = latestGestureScale //修改实时更新的值为手势最新动态
}
.onEnded { gestureScaleAtEnd in
steadyStateZoomScale *= gestureScaleAtEnd//接收变量重命名了(原名zoomScale)
}
}
.updating的第3个接收参数是用于动画的,我们基本上用不到,课程里也没有讲这个怎么使用所以使用了忽略_代替。而第二个参数就像课程理论问题说到的,就只是一个代理,其指针指向了传入的绑定参数$gestureZoomScale,更新这个代理就是在修改$gestureZoomScale的值,所以通常情况下此变量的名字与绑定名字是一样的(1:08:14)。
上面的代码已实现了双击缩放,捏合缩放效果,现在我们再通过平移手势实现拖动画布的效果。
1、定义平衡要更新的变量:
和上面使用的变量一样,我们需要复制一份修改一下名字即可:
//平移手势参数(这里的+号功能是通过扩展CGSize实际的,详细请看上一课的扩展代码)
@State private var steadyStatePanOffset = CGSize.zero //平移函数将要修改的值
@GestureState private var gesturePanOffset = CGSize.zero //通过手势实现更新的平移值
private var panOffset: CGSize {
(steadyStatePanOffset + gesturePanOffset) * zoomScale //平移位置根据2个值更新后还需要 * zoomScale
}
CGSize是不能相加的,之所以可以在上面代码里实现计算功能,因为上一节课的扩展里已为其实现了func +功能。理解panOffset计算属性为重点内容。
2、修正数据转换问题(1:15:20)
在双击缩放的时间我们修正了convertFromEmojiCoordinates与convertToEmojiCoordinates这两个函数。由于我们是移动,所以坐标是肯定会改变的,所以我们需要convertToEmojiCoordinates减去 panOffset 的高、宽值。而convertFromEmojiCoordinates则需要加上 panOffset的宽、高值,即:
private func convertToEmojiCoordinates(_ location: CGPoint, in geometry: GeometryProxy) -> (x: Int, y: Int) {
let center = geometry.frame(in: .local).center
let location = CGPoint(
x: (location.x - panOffset.width - center.x) * zoomScale,//x坐标减去 panOffset.width
y: (location.y - panOffset.height - center.y) * zoomScale//y坐标减去 panOffset.height
)
return (Int(location.x), Int(location.y))
}
private func convertFromEmojiCoordinates(_ location: (x: Int, y: Int), in geometry: GeometryProxy) -> CGPoint {
let center = geometry.frame(in: .local).center //来源于geometry的中心点(能过扩展代码extension CGRect实现)
return CGPoint(
x: center.x + CGFloat(location.x) * zoomScale + panOffset.width, //坐标x 加上 + panOffset.width
y: center.y + CGFloat(location.y) * zoomScale + panOffset.height //坐标y 加上 + panOffset.height
)
}
3、双击后清除平移
我们需要双击后让我们手工平移的状态重置,我们需要在zoomToFit后面增加参数:
steadyStatePanOffset = .zero//自动缩放时让移动位置归零
4、创建平移手势(1:18:00)
经过上面的准备工作后,我们需要建立一个平移的手势。
//平移手势
private func panGesture() -> some Gesture {
DragGesture()
.updating($gesturePanOffset) { latestDragGestureValue, gesturePanOffset, _ in
gesturePanOffset = latestDragGestureValue.translation / zoomScale//实时更新平移位置
}
.onEnded { finalDragGestureValue in
//手势结束后,位置更新
steadyStatePanOffset = steadyStatePanOffset + (finalDragGestureValue.translation / zoomScale)
}
}
这里需要重点理解.updating实时更新时的算法。这里需要查看文档.translation 从拖动手势开始到拖动手势的当前事件的总转换。
5、将手势应用到视图上(1:22:22)
将原来的.gesture(zoomGesture())替换为下面的代码:
.gesture(panGesture().simultaneously(with: zoomGesture()))//可同时应用多个非离散手势
不建议在一个视图上同时应用超过一个的手势。所以我们需要使用.simultaneously使用。
本课的内容重点是理解各种手势的应用及位置更新与缩放算法。
除非注明,网络人的文章均为原创,转载请以链接形式标明本文地址:https://www.55mx.com/post/155
《CS193p2021学习笔记第十课:Multithreading Demo Gestures 多线程演示与手势》的网友评论(0)