在UIKit中,我将图像嵌入到UIScrollView中,它会自动处理这些操作,但是我不知道如何在SwiftUI中实现。 我尝试使用MagnificationGesture,但无法使其平滑运行。
我已经搜索了一段时间,有人知道是否有更简单的方法吗?
这里的其他答案都过于复杂,使用自定义缩放逻辑。如果你想要标准、经过实战检验的UIScrollView缩放行为,你可以直接使用UIScrollView!
SwiftUI允许你使用UIViewRepresentable
或UIViewControllerRepresentable
将任何UIView放置在另一个SwiftUI视图层次结构中。然后,为了在该视图中放置更多的SwiftUI内容,你可以使用UIHostingController
。在与UIKit交互和API文档中了解有关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
}
}
}
SwiftUI的API在这里相当无用:onChanged提供了相对于当前缩放手势开始的数字,而在回调中没有明显的方法可以获取初始值。还有一个onEnded回调,但很容易被忽略/遗忘。
一个解决方法是添加:
@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
})
如果您直接设置标度,它将变得混乱,因为每次tick的数量都将与先前的数量相关。新比例尺是您自己跟踪的比例(可能是状态或绑定)。
这里是一种在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())
}
}
UIScrollView
及其缩放功能。 - Avario我认为值得一提的极其简单的方法是 - 使用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
}
}
优点:
如果您只是为了查看图像而呈现图像,则此方法可能非常适合您。但是,如果您想添加图像注释等内容,我建议您遵循其他答案中的一个。
编辑以添加view.autoScales = true
按照maka的建议。
view.autoScales = true
。 - maka这是我提供的解决方案,可以像苹果的照片应用程序一样缩放图片。
import SwiftUI
public struct SwiftUIImageViewer: View {
let image: Image
@State private var scale: CGFloat = 1
@State private var lastScale: CGFloat = 1
@State private var offset: CGPoint = .zero
@State private var lastTranslation: CGSize = .zero
public init(image: Image) {
self.image = image
}
public var body: some View {
GeometryReader { proxy in
ZStack {
image
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.offset(x: offset.x, y: offset.y)
.gesture(makeDragGesture(size: proxy.size))
.gesture(makeMagnificationGesture(size: proxy.size))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.edgesIgnoringSafeArea(.all)
}
}
private func makeMagnificationGesture(size: CGSize) -> some Gesture {
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
// To minimize jittering
if abs(1 - delta) > 0.01 {
scale *= delta
}
}
.onEnded { _ in
lastScale = 1
if scale < 1 {
withAnimation {
scale = 1
}
}
adjustMaxOffset(size: size)
}
}
private func makeDragGesture(size: CGSize) -> some Gesture {
DragGesture()
.onChanged { value in
let diff = CGPoint(
x: value.translation.width - lastTranslation.width,
y: value.translation.height - lastTranslation.height
)
offset = .init(x: offset.x + diff.x, y: offset.y + diff.y)
lastTranslation = value.translation
}
.onEnded { _ in
adjustMaxOffset(size: size)
}
}
private func adjustMaxOffset(size: CGSize) {
let maxOffsetX = (size.width * (scale - 1)) / 2
let maxOffsetY = (size.height * (scale - 1)) / 2
var newOffsetX = offset.x
var newOffsetY = offset.y
if abs(newOffsetX) > maxOffsetX {
newOffsetX = maxOffsetX * (abs(newOffsetX) / newOffsetX)
}
if abs(newOffsetY) > maxOffsetY {
newOffsetY = maxOffsetY * (abs(newOffsetY) / newOffsetY)
}
let newOffset = CGPoint(x: newOffsetX, y: newOffsetY)
if newOffset != offset {
withAnimation {
offset = newOffset
}
}
self.lastTranslation = .zero
}
}
此外,我在GitHub上有一个Swift Package解决方案,链接在这里。
其他答案也不错,这里再给一个提示:如果你正在使用SwiftUI手势,可以使用@GestureState
来代替@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
})
add image with scale and state.
add 2 gestures that work simultaneously
add also a "reset" via double tap
import SwiftUI
struct ContentView: View {
@GestureState private var scaleState: CGFloat = 1
@GestureState private var offsetState = CGSize.zero
@State private var offset = CGSize.zero
@State private var scale: CGFloat = 1
func resetStatus(){
self.offset = CGSize.zero
self.scale = 1
}
init(){
resetStatus()
}
var zoomGesture: 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 doubleTapGesture : some Gesture {
TapGesture(count: 2).onEnded { value in
resetStatus()
}
}
var body: some View {
Image(systemName: "paperplane")
.renderingMode(.template)
.resizable()
.foregroundColor(.red)
.scaledToFit()
.scaleEffect(self.scale * scaleState)
.offset(x: offset.width + offsetState.width, y: offset.height + offsetState.height)
.gesture(SimultaneousGesture(zoomGesture, dragGesture))
.gesture(doubleTapGesture)
}
}
scaleState
更改时调整offsetState
,但数学超出了我的能力范围... - Ruben Martinez Jr.offset = CGSize(width: offset.width * (newScale / oldScale), height: offset.height * (newScale / oldScale))
。您将不得不在另一个 zoomGesture
的 updating
块中更新手势状态,然后在 onEnded
中更新 offset
。 - Ruben Martinez Jr.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)
}
}
附言:你可能会发现,使用 ScrollView
来实现此目的很笨重,并且无法同时拖动和缩放。如果是这种情况,而您对此不满意,我建议您考虑添加多个手势并手动调整内容的偏移量,而不是使用 ScrollView
。
我也在为这个问题苦苦挣扎。但是有一些工作样本是通过这个视频制作的-(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
}
}
simultaneously
已更名为simultaneousGesture
。 - Zhou Haibo这是基于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 { $0.identifier == Constraint.top.rawValue }
let leadingConstraint = scrollView.constraints.first { $0.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)
}
}
}