I have a simple test application and I want to pan an image inside its view. It will not pan or zoom and I can't see what's wrong with my code.
I have followed this tutorial but implemented it in code. I've made the image width the same as the height so I can pan without necessarily zooming.
Here is my code
import UIKit
class ViewController: UIViewController, UIScrollViewDelegate {
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
return scrollView
}()
let zoomImageView: UIImageView = {
let imageView = UIImageView()
imageView.isUserInteractionEnabled = true
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.image = #imageLiteral(resourceName: "lighthouse")
return imageView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let screenSize = UIScreen.main.bounds
let screenHeight = screenSize.height
scrollView.frame = CGRect(x:0, y:0, width: screenHeight, height: screenHeight)
scrollView.delegate = self
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 3.0
zoomImageView.frame = CGRect(x:0, y:0, width: screenHeight, height: screenHeight)
scrollView.addSubview(self.zoomImageView)
view.addSubview(scrollView)
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return zoomImageView
}
}
Search in your code for the term contentSize. You don't see it, do you? But the most fundamental fact about how a scroll view works is this: a scroll view without a contentSize cannot scroll (i.e. "pan" as you put it). In particular, it must have a content size larger than its own bounds size in order to be able to scroll along that axis (height or width, or both). Otherwise, there is nothing to scroll.
Related
I have been looking for a short, reusable piece of code that allows to zoom and drag any view in SwiftUI, and also to change the scale independently.
This would be the answer.
The interesting part that I add is that the scale of the zoomed View can be controled from outside via a binding property. So we don't need to depend just on the pinching gesture, but can add a double tap to get the maximum scale, return to the normal scale, or have a slider (for instance) that changes the scale as we please.
I owe the bulk of this code to jtbandes in his answer to this question.
Here you have in a single file the code of the Zoomable and Scrollable view and a Test View to show how it works:
`
import SwiftUI
let maxAllowedScale = 4.0
struct TestZoomableScrollView: View {
#State private var scale: CGFloat = 1.0
var doubleTapGesture: some Gesture {
TapGesture(count: 2).onEnded {
if scale < maxAllowedScale / 2 {
scale = maxAllowedScale
} else {
scale = 1.0
}
}
}
var body: some View {
VStack(alignment: .center) {
Spacer()
ZoomableScrollView(scale: $scale) {
Image("foto_producto")
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
}
.frame(width: 300, height: 300)
.border(.black)
.gesture(doubleTapGesture)
Spacer()
Text("Change the scale")
Slider(value: $scale, in: 0.5...maxAllowedScale + 0.5)
.padding(.horizontal)
Spacer()
}
}
}
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
#Binding private var scale: CGFloat
init(scale: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self._scale = scale
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = maxAllowedScale
scrollView.minimumZoomScale = 1
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
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), scale: $scale)
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = self.content
uiView.zoomScale = scale
assert(context.coordinator.hostingController.view.superview == uiView)
}
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
#Binding var scale: CGFloat
init(hostingController: UIHostingController<Content>, scale: Binding<CGFloat>) {
self.hostingController = hostingController
self._scale = scale
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
self.scale = scale
}
}
}
`
I think it's the shortest, easiest way to get the desired behaviour. Also, it works perfectly, something that I haven't found in other solutions offered here. For example, the zooming out is smooth and usually it can be jerky if you don't use this approach.
The slider hast that range to show how the minimun and maximum values are respected, in a real app the range would be 1...maxAllowedScale.
As for the double tap, the behaviour can be changed very easily depending pm what you prefer.
I attach video to show everything at once:
I hope this helps anyone who's looking for this feature.
I can't find a way to make a UIImageView wrapped in a UIViewRepresentable be sized to fit the frame. It always resizes beyond the screen no matter what content Mode or clipping or explcit framing I do. (The image dimensions are much larger than the device screen frame)
To clarify: I need to use UIImageView due to some subview positioning I have to do down the line and various other reasons.
Here's a paired down example:
struct ImageView: UIViewRepresentable {
var image: UIImage
func makeUIView(context: Context) -> some UIView {
let imageView = UIImageView()
imageView.image = image
imageView.backgroundColor = .red
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.frame = CGRect(x: 0, y: 0, width: 300, height: 400)
return imageView
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
Then this is how I'm trying to implement it
struct ContentView: View {
var body: some View {
ImageView(image: UIImage(named: "full-ux-bg-image")!)
//.frame(width: 300, height: 400, alignment: .center) //This is just a test of explicit sizing
.padding()
}
}
Any ideas how to make this work? I want it to fit in the SwiftUI view without going over.
It has default constrains for content hugging/compression, to have possibility to manipulate with view externally we need to lowered those (... and never set frame for representable, just in case)
Here is fixed variant (tested with Xcode 14 / iOS 16)
func makeUIView(context: Context) -> some UIView {
let imageView = UIImageView()
imageView.image = image
imageView.backgroundColor = .red
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return imageView
}
I have some experience in SwiftUI, but am new to UIKit.
I'd like to import the zoom and position from one instance of an UIViewRepresentable UIKit ScrollView to another. So, basically, the user scrolls and zooms and later, in another branch of the view hierarchy, I want to start zoomed in at that zoom and position. I can't get it to work though, even after many attempts.
Below is my makeUIView function where I try to set the position and zoom that I want (after some initial setup).
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.bouncesZoom = true
scrollView.delaysContentTouches = false
scrollView.maximumZoomScale = 0.85 * screenScale * 10
scrollView.minimumZoomScale = 0.85 * screenScale
// 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)
/*
Here I add the zoom and position
*/
scrollView.zoomScale = 0.85 * screenscale
// add zoom and content offset
if let zoomScale = zoomScale, let contentOffset = contentOffset {
scrollView.contentOffset = contentOffset
// make sure it is within the bounds
var newZoomScale = zoomScale
if zoomScale < scrollView.minimumZoomScale {
print("too small")
newZoomScale = scrollView.minimumZoomScale
} else if zoomScale > scrollView.maximumZoomScale {
print("too large")
newZoomScale = scrollView.maximumZoomScale
}
scrollView.setContentOffset(contentOffset, animated: true)
scrollView.setZoomScale(newZoomScale, animated: true)
}
return scrollView
}
The way I get the zoom and contentOffset in the first place is to grab the values from the Coordinator in first ScrollView instance using the below code. As far as I can tell this works well and I get updates with sensible values after zooming or scrolling. The first code snippet contains the makeCoordinator function where I initiate the coordinator with methods from an environmentObject (which then updates said object). The second snippet contains the Coordinator.
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content),
userScrolledAction: drawingModel.userScrollAction,
userZoomedAction: drawingModel.userZoomAction)
}
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
let userScrolledAction: (CGPoint) -> Void
let userZoomedAction: (CGFloat) -> Void
init(hostingController: UIHostingController<Content>, userScrolledAction: #escaping (CGPoint) -> Void, userZoomedAction: #escaping (CGFloat) -> Void) {
self.hostingController = hostingController
self.userScrolledAction = userScrolledAction
self.userZoomedAction = userZoomedAction
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
userScrolledAction(scrollView.contentOffset)
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
userZoomedAction(scrollView.zoomScale)
}
}
I'd like to be able to take an Screenshot of a SwiftUI View in a XCTest.
I've tried things like the hackingwithswifts extension: https://www.hackingwithswift.com/quick-start/swiftui/how-to-convert-a-swiftui-view-to-an-image
However, my use case it slightly different, I need it to run in a XCTest. I've also seen pointfreeco snapshot testing, however, I want to understand why what I've written just produces either a black or empty image.
I've tried using a displayLink to screenshot during a loop, but the image is still empty. I feel I'm missing something fundamental.
Can anyone offer any help? Thank you
import SwiftUI
import XCTest
final class MyTests: XCTestCase {
func test_screenshot_view() throws {
let swiftUIView = Button {
} label: {
Text("Hello, World!")
}
.frame(width: 140, height: 56)
let controller = UIHostingController(rootView: swiftUIView)
let window = UIWindow()
window.rootViewController = controller
let view = controller.view
let targetSize = controller.view.intrinsicContentSize
view!.bounds = CGRect(origin: .zero, size: targetSize)
view!.backgroundColor = UIColor.yellow
UIGraphicsBeginImageContextWithOptions(view!.bounds.size, view!.isOpaque, 0)
view!.drawHierarchy(in: view!.bounds, afterScreenUpdates: true)
let snapshotImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
//snapshotImage is either black or empty
}
}
Just an update, this seems to work, but has some sizing issues:
import SwiftUI
import CoreGraphics
extension View {
func asImage() -> UIImage {
let controller = UIHostingController(rootView: self)
let view = controller.view
let targetSize = controller.view.intrinsicContentSize
let bounds = CGRect(origin: .zero, size: targetSize)
let window = UIWindow(frame: bounds)
window.rootViewController = controller
window.makeKeyAndVisible()
view?.bounds = bounds
view?.backgroundColor = .clear
let image = controller.view.asImage()
return image
}
}
extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
I have a UIImageView that I draw a triangle on. Points "A" leftPost & "B" rightPost are always static and the same distance from one another. The "tip" or point "C" is determined by tapping on the UIImageView. While the image is in portrait mode, I have no problem recreating the UIImageView along with the coordinates of the tapped CGPoint.
However, I need to display the UIImage in landscape mode and represent the same triangle. How do I "rotate" my coordinates to achieve this? I am also implying that I am NOT rotating the screen from portrait and landscape. The app is always in landscape.
import UIKit
class ViewController: UIViewController {
let bezierPath = UIBezierPath()
let shapeLayer = CAShapeLayer()
#IBOutlet weak var imageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
let shotGesture = UITapGestureRecognizer(target: self, action: #selector(shotOnNet))
imageView.addGestureRecognizer(shotGesture)
//Enable tapping on screen
imageView.isUserInteractionEnabled = true
}
func setupBezierPath(tip: CGPoint) {
let leftPost = CGPoint(x: 20, y: 50)
let rightPost = CGPoint(x: 100, y: 50)
bezierPath.move(to: leftPost)
bezierPath.addLine(to: rightPost)
bezierPath.addLine(to: tip)
bezierPath.close()
}
func setupShapeLayer() {
shapeLayer.path = bezierPath.cgPath
shapeLayer.fillColor = UIColor.red.cgColor
shapeLayer.lineWidth = 1.0
shapeLayer.strokeColor = UIColor.black.cgColor
}
func shotOnNet(_ sender: Any) {
let shot = (sender as! UITapGestureRecognizer).location(ofTouch: 0, in: imageView)
setupBezierPath(tip: shot)
setupShapeLayer()
imageView.layer.addSublayer(shapeLayer)
}
}