在 Swiftui 中没有一种简单的捏合放大图像的方法吗?
Isn't there an easy way to pinch to zoom in an image in Swiftui?
我希望能够在 SwiftUI 中调整图像大小和移动图像(就像它是地图一样)并通过捏合缩放和拖动图像。
我使用 UIKit 将图像嵌入到 UIScrollView
中并由它处理,但我不知道如何在 SwiftUI 中执行此操作。
我尝试使用 MagnificationGesture
,但无法顺利运行。
我已经搜索了一段时间了,有没有人知道是否有更简单的方法?
看起来 SwiftUI 的 ScrollView 中没有原生支持,但是,仍然有一种非常简单的方法可以做到这一点。
像您想要的那样创建一个 MagnificationGesture
,但一定要将您当前的比例乘以您在手势的 .onChanged
闭包中获得的值。此闭包为您提供 缩放 更改,而不是当前比例值。
当您缩小并开始放大时,它不会从当前比例增加(任意示例为 0.5 到 0.6),它会从 1 增加到 1.1。这就是您看到奇怪行为的原因。
如果 MagnificationGesture
与 .scaleEffect
在同一视图中,则此答案有效。否则,詹姆斯的回答会更好。
struct ContentView: View {
@State var scale: CGFloat
var body: some View {
let gesture = MagnificationGesture(minimumScaleDelta: 0.1)
.onChanged { scaleDelta in
self.scale *= scaleDelta
}
return ScrollView {
// Your ScrollView content here :)
}
.gesture(gesture)
.scaleEffect(scale)
}
}
P.S。您可能会发现为此目的使用 ScrollView
很笨拙,而且您无法同时进行拖动和缩放。如果是这种情况并且您对此不满意,我会考虑添加多个手势并手动调整内容的偏移量,而不是使用 ScrollView
.
SwiftUI API 在这里非常无用:onChanged 给出了相对于当前缩放手势开始的数字,并且在回调中没有明显的方法来获取初始值。并且有一个onEnded回调但是很容易miss/forget.
解决方法,添加:
@State var lastScaleValue: CGFloat = 1.0
然后在回调中:
.gesture(MagnificationGesture().onChanged { val in
let delta = val / self.lastScaleValue
self.lastScaleValue = val
let newScale = self.scale * delta
//... anything else e.g. clamping the newScale
}.onEnded { val in
// without this the next gesture will be broken
self.lastScaleValue = 1.0
}
其中 newScale 是您自己的比例跟踪(可能是状态或绑定)。如果你直接设置你的比例,它会变得混乱,因为每个刻度的数量将相对于之前的数量。
这是一种向 SwiftUI 视图添加双指缩放的方法。它在 UIViewRepresentable
中用 UIPinchGestureRecognizer
覆盖 UIView
,并通过绑定将相关值转发回 SwiftUI。
您可以添加这样的行为:
Image("Zoom")
.pinchToZoom()
这会添加类似于在 Instagram 提要中缩放照片的行为。这是完整的代码:
import UIKit
import SwiftUI
class PinchZoomView: UIView {
weak var delegate: PinchZoomViewDelgate?
private(set) var scale: CGFloat = 0 {
didSet {
delegate?.pinchZoomView(self, didChangeScale: scale)
}
}
private(set) var anchor: UnitPoint = .center {
didSet {
delegate?.pinchZoomView(self, didChangeAnchor: anchor)
}
}
private(set) var offset: CGSize = .zero {
didSet {
delegate?.pinchZoomView(self, didChangeOffset: offset)
}
}
private(set) var isPinching: Bool = false {
didSet {
delegate?.pinchZoomView(self, didChangePinching: isPinching)
}
}
private var startLocation: CGPoint = .zero
private var location: CGPoint = .zero
private var numberOfTouches: Int = 0
init() {
super.init(frame: .zero)
let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:)))
pinchGesture.cancelsTouchesInView = false
addGestureRecognizer(pinchGesture)
}
required init?(coder: NSCoder) {
fatalError()
}
@objc private func pinch(gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
isPinching = true
startLocation = gesture.location(in: self)
anchor = UnitPoint(x: startLocation.x / bounds.width, y: startLocation.y / bounds.height)
numberOfTouches = gesture.numberOfTouches
case .changed:
if gesture.numberOfTouches != numberOfTouches {
// If the number of fingers being used changes, the start location needs to be adjusted to avoid jumping.
let newLocation = gesture.location(in: self)
let jumpDifference = CGSize(width: newLocation.x - location.x, height: newLocation.y - location.y)
startLocation = CGPoint(x: startLocation.x + jumpDifference.width, y: startLocation.y + jumpDifference.height)
numberOfTouches = gesture.numberOfTouches
}
scale = gesture.scale
location = gesture.location(in: self)
offset = CGSize(width: location.x - startLocation.x, height: location.y - startLocation.y)
case .ended, .cancelled, .failed:
isPinching = false
scale = 1.0
anchor = .center
offset = .zero
default:
break
}
}
}
protocol PinchZoomViewDelgate: AnyObject {
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool)
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat)
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint)
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize)
}
struct PinchZoom: UIViewRepresentable {
@Binding var scale: CGFloat
@Binding var anchor: UnitPoint
@Binding var offset: CGSize
@Binding var isPinching: Bool
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> PinchZoomView {
let pinchZoomView = PinchZoomView()
pinchZoomView.delegate = context.coordinator
return pinchZoomView
}
func updateUIView(_ pageControl: PinchZoomView, context: Context) { }
class Coordinator: NSObject, PinchZoomViewDelgate {
var pinchZoom: PinchZoom
init(_ pinchZoom: PinchZoom) {
self.pinchZoom = pinchZoom
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) {
pinchZoom.isPinching = isPinching
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) {
pinchZoom.scale = scale
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) {
pinchZoom.anchor = anchor
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) {
pinchZoom.offset = offset
}
}
}
struct PinchToZoom: ViewModifier {
@State var scale: CGFloat = 1.0
@State var anchor: UnitPoint = .center
@State var offset: CGSize = .zero
@State var isPinching: Bool = false
func body(content: Content) -> some View {
content
.scaleEffect(scale, anchor: anchor)
.offset(offset)
.animation(isPinching ? .none : .spring())
.overlay(PinchZoom(scale: $scale, anchor: $anchor, offset: $offset, isPinching: $isPinching))
}
}
extension View {
func pinchToZoom() -> some View {
self.modifier(PinchToZoom())
}
}
我也在为这个问题苦恼。但是一些工作样本是用这个视频制作的-(https://www.youtube.com/watch?v=p0SwXJYJp2U)
这还没有完成。很难用锚点进行缩放。希望这是对其他人的提示。
struct ContentView: View {
let maxScale: CGFloat = 3.0
let minScale: CGFloat = 1.0
@State var lastValue: CGFloat = 1.0
@State var scale: CGFloat = 1.0
@State var draged: CGSize = .zero
@State var prevDraged: CGSize = .zero
@State var tapPoint: CGPoint = .zero
@State var isTapped: Bool = false
var body: some View {
let magnify = MagnificationGesture(minimumScaleDelta: 0.2)
.onChanged { value in
let resolvedDelta = value / self.lastValue
self.lastValue = value
let newScale = self.scale * resolvedDelta
self.scale = min(self.maxScale, max(self.minScale, newScale))
print("delta=\(value) resolvedDelta=\(resolvedDelta) newScale=\(newScale)")
}
let gestureDrag = DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { (value) in
self.tapPoint = value.startLocation
self.draged = CGSize(width: value.translation.width + self.prevDraged.width,
height: value.translation.height + self.prevDraged.height)
}
return GeometryReader { geo in
Image("dooli")
.resizable().scaledToFit().animation(.default)
.offset(self.draged)
.scaleEffect(self.scale)
// .scaleEffect(self.isTapped ? 2 : 1,
// anchor: UnitPoint(x: self.tapPoint.x / geo.frame(in: .local).maxX,
// y: self.tapPoint.y / geo.frame(in: .local).maxY))
.gesture(
TapGesture(count: 2).onEnded({
self.isTapped.toggle()
if self.scale > 1 {
self.scale = 1
} else {
self.scale = 2
}
let parent = geo.frame(in: .local)
self.postArranging(translation: CGSize.zero, in: parent)
})
.simultaneously(with: gestureDrag.onEnded({ (value) in
let parent = geo.frame(in: .local)
self.postArranging(translation: value.translation, in: parent)
})
))
.gesture(magnify.onEnded { value in
// without this the next gesture will be broken
self.lastValue = 1.0
let parent = geo.frame(in: .local)
self.postArranging(translation: CGSize.zero, in: parent)
})
}
.frame(height: 300)
.clipped()
.background(Color.gray)
}
private func postArranging(translation: CGSize, in parent: CGRect) {
let scaled = self.scale
let parentWidth = parent.maxX
let parentHeight = parent.maxY
let offset = CGSize(width: (parentWidth * scaled - parentWidth) / 2,
height: (parentHeight * scaled - parentHeight) / 2)
print(offset)
var resolved = CGSize()
let newDraged = CGSize(width: self.draged.width * scaled,
height: self.draged.height * scaled)
if newDraged.width > offset.width {
resolved.width = offset.width / scaled
} else if newDraged.width < -offset.width {
resolved.width = -offset.width / scaled
} else {
resolved.width = translation.width + self.prevDraged.width
}
if newDraged.height > offset.height {
resolved.height = offset.height / scaled
} else if newDraged.height < -offset.height {
resolved.height = -offset.height / scaled
} else {
resolved.height = translation.height + self.prevDraged.height
}
self.draged = resolved
self.prevDraged = resolved
}
}
struct DetailView: View {
var item: MenuItem
@State private var zoomed:Bool = false
@State var scale: CGFloat = 1.0
@State var isTapped: Bool = false
@State var pointTaped: CGPoint = CGPoint.zero
@State var draggedSize: CGSize = CGSize.zero
@State var previousDraged: CGSize = CGSize.zero
var width = UIScreen.main.bounds.size.width
var height = UIScreen.main.bounds.size.height
var body: some View {
GeometryReader { reader in
VStack(alignment: .center) {
ScrollView(){
HStack {
ScrollView(.vertical){
Image(self.item.mainImage)
.resizable()
.scaledToFill()
.animation(.default).offset(x: self.draggedSize.width, y: 0)
.scaleEffect(self.scale).scaleEffect(self.isTapped ? 2 : 1, anchor: UnitPoint(x : (self.pointTaped.x) / (reader.frame(in : .global).maxX),y: (self.pointTaped.y) / (reader.frame(in : .global).maxY )))
.gesture(TapGesture(count: 2)
.onEnded({ value in
self.isTapped = !self.isTapped
})
.simultaneously(with: DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { (value) in
self.pointTaped = value.startLocation
self.draggedSize = CGSize(width: value.translation.width + self.previousDraged.width, height: value.translation.height + self.previousDraged.height)
}
.onEnded({ (value) in
let offSetWidth = (reader.frame(in :.global).maxX * self.scale) - (reader.frame(in :.global).maxX) / 2
let newDraggedWidth = self.previousDraged.width * self.scale
if (newDraggedWidth > offSetWidth){
self.draggedSize = CGSize(width: offSetWidth / self.scale, height: value.translation.height + self.previousDraged.height)
}
else if (newDraggedWidth < -offSetWidth){
self.draggedSize = CGSize(width: -offSetWidth / self.scale, height: value.translation.height + self.previousDraged.height)
}
else{
self.draggedSize = CGSize(width: value.translation.width + self.previousDraged.width, height: value.translation.height + self.previousDraged.height)
}
self.previousDraged = self.draggedSize
})))
.gesture(MagnificationGesture()
.onChanged { (value) in
self.scale = value.magnitude
}.onEnded { (val) in
//self.scale = 1.0
self.scale = val.magnitude
}
)
}
}
HStack {
Text(self.item.description)
.foregroundColor(Color.black)
.multilineTextAlignment(.leading)
.padding(4)
}
}
}.navigationBarTitle("Menu Detail")
}
}
}
此处的其他答案使用自定义缩放逻辑过于复杂。如果您想要标准,battle-tested UIScrollView 缩放行为,您可以使用 UIScrollView!
SwiftUI 允许您使用 UIViewRepresentable
或 UIViewControllerRepresentable
将任何 UIView 放入其他 SwiftUI 视图层次结构中。然后要在该视图中放置更多 SwiftUI 内容,您可以使用 UIHostingController
。在 Interfacing with UIKit and the API docs.
中阅读有关 SwiftUI–UIKit 互操作的更多信息
您可以在以下位置找到我在真实应用中使用它的更完整的示例:https://github.com/jtbandes/SpacePOD/blob/main/SpacePOD/ZoomableScrollView.swift(该示例还包含更多使图像居中的技巧。)
var body: some View {
ZoomableScrollView {
Image("Your image here")
}
}
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = 20
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
// create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = self.content
assert(context.coordinator.hostingController.view.superview == uiView)
}
// MARK: - Coordinator
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
init(hostingController: UIHostingController<Content>) {
self.hostingController = hostingController
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
}
}
这是@James 和@ethoooo 的另一种方法。最终缩放状态和瞬态手势状态保持分开(瞬态将始终 return 1),因此除了手势本身之外,您还可以通过按钮或步进器设置这种状态。
@State var scrollContentZoom: CGFloat = 1
@GestureState var scrollContentGestureZoom: CGFloat = 1
var contentZoom: CGFloat { scrollContentZoom*scrollContentGestureZoom }
var magnification: some Gesture {
MagnificationGesture()
.updating($scrollContentGestureZoom) { state, gestureState, transaction in
print("Magnifed: \(state)")
gestureState = state
}
.onEnded { (state) in
scrollContentZoom = contentZoom*state
}
}
其他答案都很好,这里有一个额外的提示:如果您使用的是 SwiftUI 手势,则可以使用 @GestureState
而不是 @State
来存储手势状态。它会在手势结束后自动将状态重置为初始值,因此您可以简化这种代码:
@State private var scale: CGFloat = 1.0
.gesture(MagnificationGesture().onChanged { value in
// Anything with value
scale = value
}.onEnded { value in
scale = 1.0
})
与:
@GestureState private var scale: CGFloat = 1.0
.gesture(MagnificationGesture().updating($scale) { (newValue, scale, _) in
// Anything with value
scale = newValue
})
这是@James 接受的响应的完整示例,它还具有通过调整隐藏矩形来滚动新缩放图像的基本支持,该矩形根据图像比例调整滚动视图内容的大小:
import SwiftUI
struct EnlargedImage: View {
var image = UIImage(named: "YourImageName")
@State var scale: CGFloat = 1.0
@State var lastScaleValue: CGFloat = 1.0
var body: some View {
ScrollView([.vertical, .horizontal], showsIndicators: false){
ZStack{
Rectangle().foregroundColor(.clear).frame(width: image!.size.width * scale, height: image!.size.height * scale, alignment: .center)
Image(uiImage: image!).scaleEffect(scale)
.gesture(MagnificationGesture().onChanged { val in
let delta = val / self.lastScaleValue
self.lastScaleValue = val
var newScale = self.scale * delta
if newScale < 1.0
{
newScale = 1.0
}
scale = newScale
}.onEnded{val in
lastScaleValue = 1
})
}
}.background(Color(.systemBackground).edgesIgnoringSafeArea(.all))
}
}
我在 my GitHub 中有更好的版本。
我认为值得一提的极其简单的方法 - 使用 Apple 的 PDFKit
。
import SwiftUI
import PDFKit
struct PhotoDetailView: UIViewRepresentable {
let image: UIImage
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.document = PDFDocument()
guard let page = PDFPage(image: image) else { return view }
view.document?.insert(page, at: 0)
view.autoScales = true
return view
}
func updateUIView(_ uiView: PDFView, context: Context) {
// empty
}
}
优点:
- 需要 0 个逻辑
- 感觉很专业
- 由 Apple 编写(将来不太可能破解)
如果您只是展示图片以供查看,此方法可能最适合您。但是如果你想添加图像注释等,我会遵循其他答案之一。
根据 maka 的建议编辑添加 view.autoScales = true
。
这是另一个解决方案,基于 jtbandes 的回答。它仍然将 UIScrollView
包装在 UIViewRepresentable
中,但有一些变化:
- 它专门针对
UIImage
,而不是通用的 SwiftUI 内容:它适用于这种情况,不需要将底层 UIImage
包装到 SwiftUI Image
- 它根据自动布局约束布置图像视图,而不是自动调整大小蒙版
- 通过根据当前缩放级别计算顶部和前导约束的适当值,它使图像在视图中间居中
使用:
struct EncompassingView: View {
let uiImage: UIImage
var body: some View {
GeometryReader { geometry in
ZoomableView(uiImage: uiImage, viewSize: geometry.size)
}
}
}
定义:
struct ZoomableView: UIViewRepresentable {
let uiImage: UIImage
let viewSize: CGSize
private enum Constraint: String {
case top
case leading
}
private var minimumZoomScale: CGFloat {
let widthScale = viewSize.width / uiImage.size.width
let heightScale = viewSize.height / uiImage.size.height
return min(widthScale, heightScale)
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.maximumZoomScale = minimumZoomScale * 50
scrollView.minimumZoomScale = minimumZoomScale
scrollView.bouncesZoom = true
let imageView = UIImageView(image: uiImage)
scrollView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
let topConstraint = imageView.topAnchor.constraint(equalTo: scrollView.topAnchor)
topConstraint.identifier = Constraint.top.rawValue
topConstraint.isActive = true
let leadingConstraint = imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
leadingConstraint.identifier = Constraint.leading.rawValue
leadingConstraint.isActive = true
imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
func updateUIView(_ scrollView: UIScrollView, context: Context) {
guard let imageView = scrollView.subviews.first as? UIImageView else {
return
}
// Inject dependencies into coordinator
context.coordinator.zoomableView = imageView
context.coordinator.imageSize = uiImage.size
context.coordinator.viewSize = viewSize
let topConstraint = scrollView.constraints.first { [=11=].identifier == Constraint.top.rawValue }
let leadingConstraint = scrollView.constraints.first { [=11=].identifier == Constraint.leading.rawValue }
context.coordinator.topConstraint = topConstraint
context.coordinator.leadingConstraint = leadingConstraint
// Set initial zoom scale
scrollView.zoomScale = minimumZoomScale
}
}
// MARK: - Coordinator
extension ZoomableView {
class Coordinator: NSObject, UIScrollViewDelegate {
var zoomableView: UIView?
var imageSize: CGSize?
var viewSize: CGSize?
var topConstraint: NSLayoutConstraint?
var leadingConstraint: NSLayoutConstraint?
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
zoomableView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
let zoomScale = scrollView.zoomScale
print("zoomScale = \(zoomScale)")
guard
let topConstraint = topConstraint,
let leadingConstraint = leadingConstraint,
let imageSize = imageSize,
let viewSize = viewSize
else {
return
}
topConstraint.constant = max((viewSize.height - (imageSize.height * zoomScale)) / 2.0, 0.0)
leadingConstraint.constant = max((viewSize.width - (imageSize.width * zoomScale)) / 2.0, 0.0)
}
}
}
SwiftUI中图片缩放和拖拽的实现
struct PhotoViewer: View {
@State private var uiimage = UIImage(named: "leaf.png")
@GestureState private var scaleState: CGFloat = 1
@GestureState private var offsetState = CGSize.zero
@State private var offset = CGSize.zero
@State private var scale: CGFloat = 1
var magnification: some Gesture {
MagnificationGesture()
.updating($scaleState) { currentState, gestureState, _ in
gestureState = currentState
}
.onEnded { value in
scale *= value
}
}
var dragGesture: some Gesture {
DragGesture()
.updating($offsetState) { currentState, gestureState, _ in
gestureState = currentState.translation
}.onEnded { value in
offset.height += value.translation.height
offset.width += value.translation.width
}
}
var body: some View {
Image(uiImage: uiimage!)
.resizable()
.scaledToFit()
.scaleEffect(self.scale * scaleState)
.offset(x: offset.width + offsetState.width, y: offset.height + offsetState.height)
.gesture(SimultaneousGesture(magnification, dragGesture))
}
}
我希望能够在 SwiftUI 中调整图像大小和移动图像(就像它是地图一样)并通过捏合缩放和拖动图像。
我使用 UIKit 将图像嵌入到 UIScrollView
中并由它处理,但我不知道如何在 SwiftUI 中执行此操作。
我尝试使用 MagnificationGesture
,但无法顺利运行。
我已经搜索了一段时间了,有没有人知道是否有更简单的方法?
看起来 SwiftUI 的 ScrollView 中没有原生支持,但是,仍然有一种非常简单的方法可以做到这一点。
像您想要的那样创建一个 MagnificationGesture
,但一定要将您当前的比例乘以您在手势的 .onChanged
闭包中获得的值。此闭包为您提供 缩放 更改,而不是当前比例值。
当您缩小并开始放大时,它不会从当前比例增加(任意示例为 0.5 到 0.6),它会从 1 增加到 1.1。这就是您看到奇怪行为的原因。
如果 MagnificationGesture
与 .scaleEffect
在同一视图中,则此答案有效。否则,詹姆斯的回答会更好。
struct ContentView: View {
@State var scale: CGFloat
var body: some View {
let gesture = MagnificationGesture(minimumScaleDelta: 0.1)
.onChanged { scaleDelta in
self.scale *= scaleDelta
}
return ScrollView {
// Your ScrollView content here :)
}
.gesture(gesture)
.scaleEffect(scale)
}
}
P.S。您可能会发现为此目的使用 ScrollView
很笨拙,而且您无法同时进行拖动和缩放。如果是这种情况并且您对此不满意,我会考虑添加多个手势并手动调整内容的偏移量,而不是使用 ScrollView
.
SwiftUI API 在这里非常无用:onChanged 给出了相对于当前缩放手势开始的数字,并且在回调中没有明显的方法来获取初始值。并且有一个onEnded回调但是很容易miss/forget.
解决方法,添加:
@State var lastScaleValue: CGFloat = 1.0
然后在回调中:
.gesture(MagnificationGesture().onChanged { val in
let delta = val / self.lastScaleValue
self.lastScaleValue = val
let newScale = self.scale * delta
//... anything else e.g. clamping the newScale
}.onEnded { val in
// without this the next gesture will be broken
self.lastScaleValue = 1.0
}
其中 newScale 是您自己的比例跟踪(可能是状态或绑定)。如果你直接设置你的比例,它会变得混乱,因为每个刻度的数量将相对于之前的数量。
这是一种向 SwiftUI 视图添加双指缩放的方法。它在 UIViewRepresentable
中用 UIPinchGestureRecognizer
覆盖 UIView
,并通过绑定将相关值转发回 SwiftUI。
您可以添加这样的行为:
Image("Zoom")
.pinchToZoom()
这会添加类似于在 Instagram 提要中缩放照片的行为。这是完整的代码:
import UIKit
import SwiftUI
class PinchZoomView: UIView {
weak var delegate: PinchZoomViewDelgate?
private(set) var scale: CGFloat = 0 {
didSet {
delegate?.pinchZoomView(self, didChangeScale: scale)
}
}
private(set) var anchor: UnitPoint = .center {
didSet {
delegate?.pinchZoomView(self, didChangeAnchor: anchor)
}
}
private(set) var offset: CGSize = .zero {
didSet {
delegate?.pinchZoomView(self, didChangeOffset: offset)
}
}
private(set) var isPinching: Bool = false {
didSet {
delegate?.pinchZoomView(self, didChangePinching: isPinching)
}
}
private var startLocation: CGPoint = .zero
private var location: CGPoint = .zero
private var numberOfTouches: Int = 0
init() {
super.init(frame: .zero)
let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:)))
pinchGesture.cancelsTouchesInView = false
addGestureRecognizer(pinchGesture)
}
required init?(coder: NSCoder) {
fatalError()
}
@objc private func pinch(gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
isPinching = true
startLocation = gesture.location(in: self)
anchor = UnitPoint(x: startLocation.x / bounds.width, y: startLocation.y / bounds.height)
numberOfTouches = gesture.numberOfTouches
case .changed:
if gesture.numberOfTouches != numberOfTouches {
// If the number of fingers being used changes, the start location needs to be adjusted to avoid jumping.
let newLocation = gesture.location(in: self)
let jumpDifference = CGSize(width: newLocation.x - location.x, height: newLocation.y - location.y)
startLocation = CGPoint(x: startLocation.x + jumpDifference.width, y: startLocation.y + jumpDifference.height)
numberOfTouches = gesture.numberOfTouches
}
scale = gesture.scale
location = gesture.location(in: self)
offset = CGSize(width: location.x - startLocation.x, height: location.y - startLocation.y)
case .ended, .cancelled, .failed:
isPinching = false
scale = 1.0
anchor = .center
offset = .zero
default:
break
}
}
}
protocol PinchZoomViewDelgate: AnyObject {
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool)
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat)
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint)
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize)
}
struct PinchZoom: UIViewRepresentable {
@Binding var scale: CGFloat
@Binding var anchor: UnitPoint
@Binding var offset: CGSize
@Binding var isPinching: Bool
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> PinchZoomView {
let pinchZoomView = PinchZoomView()
pinchZoomView.delegate = context.coordinator
return pinchZoomView
}
func updateUIView(_ pageControl: PinchZoomView, context: Context) { }
class Coordinator: NSObject, PinchZoomViewDelgate {
var pinchZoom: PinchZoom
init(_ pinchZoom: PinchZoom) {
self.pinchZoom = pinchZoom
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) {
pinchZoom.isPinching = isPinching
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) {
pinchZoom.scale = scale
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) {
pinchZoom.anchor = anchor
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) {
pinchZoom.offset = offset
}
}
}
struct PinchToZoom: ViewModifier {
@State var scale: CGFloat = 1.0
@State var anchor: UnitPoint = .center
@State var offset: CGSize = .zero
@State var isPinching: Bool = false
func body(content: Content) -> some View {
content
.scaleEffect(scale, anchor: anchor)
.offset(offset)
.animation(isPinching ? .none : .spring())
.overlay(PinchZoom(scale: $scale, anchor: $anchor, offset: $offset, isPinching: $isPinching))
}
}
extension View {
func pinchToZoom() -> some View {
self.modifier(PinchToZoom())
}
}
我也在为这个问题苦恼。但是一些工作样本是用这个视频制作的-(https://www.youtube.com/watch?v=p0SwXJYJp2U)
这还没有完成。很难用锚点进行缩放。希望这是对其他人的提示。
struct ContentView: View {
let maxScale: CGFloat = 3.0
let minScale: CGFloat = 1.0
@State var lastValue: CGFloat = 1.0
@State var scale: CGFloat = 1.0
@State var draged: CGSize = .zero
@State var prevDraged: CGSize = .zero
@State var tapPoint: CGPoint = .zero
@State var isTapped: Bool = false
var body: some View {
let magnify = MagnificationGesture(minimumScaleDelta: 0.2)
.onChanged { value in
let resolvedDelta = value / self.lastValue
self.lastValue = value
let newScale = self.scale * resolvedDelta
self.scale = min(self.maxScale, max(self.minScale, newScale))
print("delta=\(value) resolvedDelta=\(resolvedDelta) newScale=\(newScale)")
}
let gestureDrag = DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { (value) in
self.tapPoint = value.startLocation
self.draged = CGSize(width: value.translation.width + self.prevDraged.width,
height: value.translation.height + self.prevDraged.height)
}
return GeometryReader { geo in
Image("dooli")
.resizable().scaledToFit().animation(.default)
.offset(self.draged)
.scaleEffect(self.scale)
// .scaleEffect(self.isTapped ? 2 : 1,
// anchor: UnitPoint(x: self.tapPoint.x / geo.frame(in: .local).maxX,
// y: self.tapPoint.y / geo.frame(in: .local).maxY))
.gesture(
TapGesture(count: 2).onEnded({
self.isTapped.toggle()
if self.scale > 1 {
self.scale = 1
} else {
self.scale = 2
}
let parent = geo.frame(in: .local)
self.postArranging(translation: CGSize.zero, in: parent)
})
.simultaneously(with: gestureDrag.onEnded({ (value) in
let parent = geo.frame(in: .local)
self.postArranging(translation: value.translation, in: parent)
})
))
.gesture(magnify.onEnded { value in
// without this the next gesture will be broken
self.lastValue = 1.0
let parent = geo.frame(in: .local)
self.postArranging(translation: CGSize.zero, in: parent)
})
}
.frame(height: 300)
.clipped()
.background(Color.gray)
}
private func postArranging(translation: CGSize, in parent: CGRect) {
let scaled = self.scale
let parentWidth = parent.maxX
let parentHeight = parent.maxY
let offset = CGSize(width: (parentWidth * scaled - parentWidth) / 2,
height: (parentHeight * scaled - parentHeight) / 2)
print(offset)
var resolved = CGSize()
let newDraged = CGSize(width: self.draged.width * scaled,
height: self.draged.height * scaled)
if newDraged.width > offset.width {
resolved.width = offset.width / scaled
} else if newDraged.width < -offset.width {
resolved.width = -offset.width / scaled
} else {
resolved.width = translation.width + self.prevDraged.width
}
if newDraged.height > offset.height {
resolved.height = offset.height / scaled
} else if newDraged.height < -offset.height {
resolved.height = -offset.height / scaled
} else {
resolved.height = translation.height + self.prevDraged.height
}
self.draged = resolved
self.prevDraged = resolved
}
}
struct DetailView: View {
var item: MenuItem
@State private var zoomed:Bool = false
@State var scale: CGFloat = 1.0
@State var isTapped: Bool = false
@State var pointTaped: CGPoint = CGPoint.zero
@State var draggedSize: CGSize = CGSize.zero
@State var previousDraged: CGSize = CGSize.zero
var width = UIScreen.main.bounds.size.width
var height = UIScreen.main.bounds.size.height
var body: some View {
GeometryReader { reader in
VStack(alignment: .center) {
ScrollView(){
HStack {
ScrollView(.vertical){
Image(self.item.mainImage)
.resizable()
.scaledToFill()
.animation(.default).offset(x: self.draggedSize.width, y: 0)
.scaleEffect(self.scale).scaleEffect(self.isTapped ? 2 : 1, anchor: UnitPoint(x : (self.pointTaped.x) / (reader.frame(in : .global).maxX),y: (self.pointTaped.y) / (reader.frame(in : .global).maxY )))
.gesture(TapGesture(count: 2)
.onEnded({ value in
self.isTapped = !self.isTapped
})
.simultaneously(with: DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { (value) in
self.pointTaped = value.startLocation
self.draggedSize = CGSize(width: value.translation.width + self.previousDraged.width, height: value.translation.height + self.previousDraged.height)
}
.onEnded({ (value) in
let offSetWidth = (reader.frame(in :.global).maxX * self.scale) - (reader.frame(in :.global).maxX) / 2
let newDraggedWidth = self.previousDraged.width * self.scale
if (newDraggedWidth > offSetWidth){
self.draggedSize = CGSize(width: offSetWidth / self.scale, height: value.translation.height + self.previousDraged.height)
}
else if (newDraggedWidth < -offSetWidth){
self.draggedSize = CGSize(width: -offSetWidth / self.scale, height: value.translation.height + self.previousDraged.height)
}
else{
self.draggedSize = CGSize(width: value.translation.width + self.previousDraged.width, height: value.translation.height + self.previousDraged.height)
}
self.previousDraged = self.draggedSize
})))
.gesture(MagnificationGesture()
.onChanged { (value) in
self.scale = value.magnitude
}.onEnded { (val) in
//self.scale = 1.0
self.scale = val.magnitude
}
)
}
}
HStack {
Text(self.item.description)
.foregroundColor(Color.black)
.multilineTextAlignment(.leading)
.padding(4)
}
}
}.navigationBarTitle("Menu Detail")
}
}
}
此处的其他答案使用自定义缩放逻辑过于复杂。如果您想要标准,battle-tested UIScrollView 缩放行为,您可以使用 UIScrollView!
SwiftUI 允许您使用 UIViewRepresentable
或 UIViewControllerRepresentable
将任何 UIView 放入其他 SwiftUI 视图层次结构中。然后要在该视图中放置更多 SwiftUI 内容,您可以使用 UIHostingController
。在 Interfacing with UIKit and the API docs.
您可以在以下位置找到我在真实应用中使用它的更完整的示例:https://github.com/jtbandes/SpacePOD/blob/main/SpacePOD/ZoomableScrollView.swift(该示例还包含更多使图像居中的技巧。)
var body: some View {
ZoomableScrollView {
Image("Your image here")
}
}
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = 20
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
// create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = self.content
assert(context.coordinator.hostingController.view.superview == uiView)
}
// MARK: - Coordinator
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
init(hostingController: UIHostingController<Content>) {
self.hostingController = hostingController
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
}
}
这是@James 和@ethoooo 的另一种方法。最终缩放状态和瞬态手势状态保持分开(瞬态将始终 return 1),因此除了手势本身之外,您还可以通过按钮或步进器设置这种状态。
@State var scrollContentZoom: CGFloat = 1
@GestureState var scrollContentGestureZoom: CGFloat = 1
var contentZoom: CGFloat { scrollContentZoom*scrollContentGestureZoom }
var magnification: some Gesture {
MagnificationGesture()
.updating($scrollContentGestureZoom) { state, gestureState, transaction in
print("Magnifed: \(state)")
gestureState = state
}
.onEnded { (state) in
scrollContentZoom = contentZoom*state
}
}
其他答案都很好,这里有一个额外的提示:如果您使用的是 SwiftUI 手势,则可以使用 @GestureState
而不是 @State
来存储手势状态。它会在手势结束后自动将状态重置为初始值,因此您可以简化这种代码:
@State private var scale: CGFloat = 1.0
.gesture(MagnificationGesture().onChanged { value in
// Anything with value
scale = value
}.onEnded { value in
scale = 1.0
})
与:
@GestureState private var scale: CGFloat = 1.0
.gesture(MagnificationGesture().updating($scale) { (newValue, scale, _) in
// Anything with value
scale = newValue
})
这是@James 接受的响应的完整示例,它还具有通过调整隐藏矩形来滚动新缩放图像的基本支持,该矩形根据图像比例调整滚动视图内容的大小:
import SwiftUI
struct EnlargedImage: View {
var image = UIImage(named: "YourImageName")
@State var scale: CGFloat = 1.0
@State var lastScaleValue: CGFloat = 1.0
var body: some View {
ScrollView([.vertical, .horizontal], showsIndicators: false){
ZStack{
Rectangle().foregroundColor(.clear).frame(width: image!.size.width * scale, height: image!.size.height * scale, alignment: .center)
Image(uiImage: image!).scaleEffect(scale)
.gesture(MagnificationGesture().onChanged { val in
let delta = val / self.lastScaleValue
self.lastScaleValue = val
var newScale = self.scale * delta
if newScale < 1.0
{
newScale = 1.0
}
scale = newScale
}.onEnded{val in
lastScaleValue = 1
})
}
}.background(Color(.systemBackground).edgesIgnoringSafeArea(.all))
}
}
我在 my GitHub 中有更好的版本。
我认为值得一提的极其简单的方法 - 使用 Apple 的 PDFKit
。
import SwiftUI
import PDFKit
struct PhotoDetailView: UIViewRepresentable {
let image: UIImage
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.document = PDFDocument()
guard let page = PDFPage(image: image) else { return view }
view.document?.insert(page, at: 0)
view.autoScales = true
return view
}
func updateUIView(_ uiView: PDFView, context: Context) {
// empty
}
}
优点:
- 需要 0 个逻辑
- 感觉很专业
- 由 Apple 编写(将来不太可能破解)
如果您只是展示图片以供查看,此方法可能最适合您。但是如果你想添加图像注释等,我会遵循其他答案之一。
根据 maka 的建议编辑添加 view.autoScales = true
。
这是另一个解决方案,基于 jtbandes 的回答。它仍然将 UIScrollView
包装在 UIViewRepresentable
中,但有一些变化:
- 它专门针对
UIImage
,而不是通用的 SwiftUI 内容:它适用于这种情况,不需要将底层UIImage
包装到 SwiftUIImage
- 它根据自动布局约束布置图像视图,而不是自动调整大小蒙版
- 通过根据当前缩放级别计算顶部和前导约束的适当值,它使图像在视图中间居中
使用:
struct EncompassingView: View {
let uiImage: UIImage
var body: some View {
GeometryReader { geometry in
ZoomableView(uiImage: uiImage, viewSize: geometry.size)
}
}
}
定义:
struct ZoomableView: UIViewRepresentable {
let uiImage: UIImage
let viewSize: CGSize
private enum Constraint: String {
case top
case leading
}
private var minimumZoomScale: CGFloat {
let widthScale = viewSize.width / uiImage.size.width
let heightScale = viewSize.height / uiImage.size.height
return min(widthScale, heightScale)
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.maximumZoomScale = minimumZoomScale * 50
scrollView.minimumZoomScale = minimumZoomScale
scrollView.bouncesZoom = true
let imageView = UIImageView(image: uiImage)
scrollView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
let topConstraint = imageView.topAnchor.constraint(equalTo: scrollView.topAnchor)
topConstraint.identifier = Constraint.top.rawValue
topConstraint.isActive = true
let leadingConstraint = imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
leadingConstraint.identifier = Constraint.leading.rawValue
leadingConstraint.isActive = true
imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
func updateUIView(_ scrollView: UIScrollView, context: Context) {
guard let imageView = scrollView.subviews.first as? UIImageView else {
return
}
// Inject dependencies into coordinator
context.coordinator.zoomableView = imageView
context.coordinator.imageSize = uiImage.size
context.coordinator.viewSize = viewSize
let topConstraint = scrollView.constraints.first { [=11=].identifier == Constraint.top.rawValue }
let leadingConstraint = scrollView.constraints.first { [=11=].identifier == Constraint.leading.rawValue }
context.coordinator.topConstraint = topConstraint
context.coordinator.leadingConstraint = leadingConstraint
// Set initial zoom scale
scrollView.zoomScale = minimumZoomScale
}
}
// MARK: - Coordinator
extension ZoomableView {
class Coordinator: NSObject, UIScrollViewDelegate {
var zoomableView: UIView?
var imageSize: CGSize?
var viewSize: CGSize?
var topConstraint: NSLayoutConstraint?
var leadingConstraint: NSLayoutConstraint?
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
zoomableView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
let zoomScale = scrollView.zoomScale
print("zoomScale = \(zoomScale)")
guard
let topConstraint = topConstraint,
let leadingConstraint = leadingConstraint,
let imageSize = imageSize,
let viewSize = viewSize
else {
return
}
topConstraint.constant = max((viewSize.height - (imageSize.height * zoomScale)) / 2.0, 0.0)
leadingConstraint.constant = max((viewSize.width - (imageSize.width * zoomScale)) / 2.0, 0.0)
}
}
}
SwiftUI中图片缩放和拖拽的实现
struct PhotoViewer: View {
@State private var uiimage = UIImage(named: "leaf.png")
@GestureState private var scaleState: CGFloat = 1
@GestureState private var offsetState = CGSize.zero
@State private var offset = CGSize.zero
@State private var scale: CGFloat = 1
var magnification: some Gesture {
MagnificationGesture()
.updating($scaleState) { currentState, gestureState, _ in
gestureState = currentState
}
.onEnded { value in
scale *= value
}
}
var dragGesture: some Gesture {
DragGesture()
.updating($offsetState) { currentState, gestureState, _ in
gestureState = currentState.translation
}.onEnded { value in
offset.height += value.translation.height
offset.width += value.translation.width
}
}
var body: some View {
Image(uiImage: uiimage!)
.resizable()
.scaledToFit()
.scaleEffect(self.scale * scaleState)
.offset(x: offset.width + offsetState.width, y: offset.height + offsetState.height)
.gesture(SimultaneousGesture(magnification, dragGesture))
}
}