How to create synced ScrollViews in SwiftUI - swiftui

I am trying to create two synced ScrollViews in SwiftUI such that scrolling in one will result in the same scrolling in the other.
I am using the ScrollViewOffset class shown at the bottom for getting a scrollView offset value but having trouble figuring out how to scroll the other view.
I seem to be able to 'hack' it by preventing scrolling in one view and setting the content position() on the other - is there any way to actually scroll the scrollView content to a position - I know ScrollViewReader seems to allow scrolling to display content items but I can't seem to find anything that will scroll the contents to an offset position.
The problem with using position() is that it does not actually change the ScrollViews scroller positions - there seems to be no ScrollView.scrollContentsTo(point: CGPoint).
#State private var scrollOffset1: CGPoint = .zero
HStack {
ScrollViewOffset(onOffsetChange: { offset in
scrollOffset1 = offset
print("New ScrollView1 offset: \(offset)")
}, content: {
VStack {
ImageView(filteredImageProvider: self.provider)
.frame(width: imageWidth, height: imageHeight)
}
.frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
.border(Color.white)
.id(0)
})
ScrollView([]) {
VStack {
ImageView(filteredImageProvider: self.provider, showEdits: false)
.frame(width: imageWidth, height: imageHeight)
}
.frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
.border(Color.white)
.id(0)
.position(x: scrollOffset1.x, y: scrollOffset1.y + (imageHeight + (geometry.size.height - 20) * 2)/2)
}
}
//
// ScrollViewOffset.swift
// ZoomView
//
//
import Foundation
import SwiftUI
struct ScrollViewOffset<Content: View>: View {
let onOffsetChange: (CGPoint) -> Void
let content: () -> Content
init(
onOffsetChange: #escaping (CGPoint) -> Void,
#ViewBuilder content: #escaping () -> Content
) {
self.onOffsetChange = onOffsetChange
self.content = content
}
var body: some View {
ScrollView([.horizontal, .vertical]) {
offsetReader
content()
.padding(.top, -8)
}
.coordinateSpace(name: "frameLayer")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: onOffsetChange)
}
var offsetReader: some View {
GeometryReader { proxy in
Color.clear
.preference(
key: ScrollOffsetPreferenceKey.self,
value: proxy.frame(in: .named("frameLayer")).origin
)
}
.frame(width: 0, height: 0)
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}

Synced scroll views.
In this example, you can scroll the LHS scrollview and the RHS scrollview will be synchronised to the same position. In this example, the scrollview on the RHS is disabled, and the position is simply synchronised by using an offset.
But using the same logic and code, you can make both the LHS and RHS scrollviews synced when either of them are scrolled.
import SwiftUI
struct ContentView: View {
#State private var offset = CGFloat.zero
var body: some View {
HStack(alignment: .top) {
// MainScrollView
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}
.background( GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { value in
print("offset >> \(value)")
offset = value
}
}
.coordinateSpace(name: "scroll")
// Synchronised with ScrollView above
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}
.offset(y: -offset)
}
.disabled(true)
}
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}

With the current API of ScrollView, this is not possible. While you can get the contentOffset of the scrollView using methods that are widely available on the internet, the ScrollViewReader that is used to programmatically scroll a ScrollView only allows you to scroll to specific views, instead of to a contentOffset.
To achieve this functionality, you are going to have to wrap UIScrollView. Here is an implementation, although it isn't 100% stable, and is missing a good amount of scrollView functionality:
import SwiftUI
import UIKit
public struct ScrollableView<Content: View>: UIViewControllerRepresentable {
#Binding var offset: CGPoint
var content: () -> Content
public init(_ offset: Binding<CGPoint>, #ViewBuilder content: #escaping () -> Content) {
self._offset = offset
self.content = content
}
public func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
vc.scrollView.setContentOffset(offset, animated: false)
vc.delegate = context.coordinator
return vc
}
public func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
// Allow for deaceleration to be done by the scrollView
if !viewController.scrollView.isDecelerating {
viewController.scrollView.setContentOffset(offset, animated: false)
}
}
public func makeCoordinator() -> Coordinator {
Coordinator(contentOffset: _offset)
}
public class Coordinator: NSObject, UIScrollViewDelegate {
let contentOffset: Binding<CGPoint>
init(contentOffset: Binding<CGPoint>) {
self.contentOffset = contentOffset
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
contentOffset.wrappedValue = scrollView.contentOffset
}
}
}
public class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = UIScrollView()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
weak var delegate: UIScrollViewDelegate?
public override func viewDidLoad() {
super.viewDidLoad()
self.scrollView.delegate = delegate
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.pinEdges(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
}
struct ScrollableView_Previews: PreviewProvider {
static var previews: some View {
Wrapper()
}
struct Wrapper: View {
#State var offset: CGPoint = .init(x: 0, y: 50)
var body: some View {
HStack {
ScrollableView($offset, content: {
ForEach(0...100, id: \.self) { id in
Text("\(id)")
}
})
ScrollableView($offset, content: {
ForEach(0...100, id: \.self) { id in
Text("\(id)")
}
})
VStack {
Text("x: \(offset.x) y: \(offset.y)")
Button("Top", action: {
offset = .zero
})
.buttonStyle(.borderedProminent)
}
.frame(width: 200)
.padding()
}
}
}
}

Related

SwiftUI UIScrollView content can be scrolled out of frame

I implemented a UIScrollView from UIKit in SwiftUI (without storyboard) because there is nothing similar in SwiftUI. It works quite well so far, but the ScrollView is not limited by the size of the content, but you can still scroll over the edge of the content. The stronger the zoom factor, the stronger this effect becomes apparent.
UIScrollView Wrapper in SwiftUI:
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
#Binding var didZoom: Bool
private var content: Content
init(didZoom: Binding<Bool>, #ViewBuilder content: () -> Content) {
_didZoom = didZoom
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.maximumZoomScale = 20
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
hostedView.backgroundColor = .black
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content), didZoom: $didZoom)
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
context.coordinator.hostingController.rootView = self.content
assert(context.coordinator.hostingController.view.superview == uiView)
}
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
#Binding var didZoom: Bool
init(hostingController: UIHostingController<Content>, didZoom: Binding<Bool>) {
self.hostingController = hostingController
_didZoom = didZoom
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
didZoom = !(scrollView.zoomScale == scrollView.minimumZoomScale)
}
}
}
SwiftUI ContentView:
struct ContentView: View {
var body: some View {
ZoomableScrollView {
Rectangle()
.frame(width: 420, height: 600)
}
}
}
You're making whole screen zoomable if you want to make only rectangle zoomable then only wrap that rectangle within zoomablescrollview
struct ContentView: View {
var body: some View {
ZStack{
ZoomableScrollView {
Rectangle()
.frame(width: 420, height: 600)
}.frame(width: 420,height: 600)
}
}
}

How to scroll to position UIScrollView in Wrapper for SwiftUI?

i have a ScrollView from UIKit and use it for SwiftUI: Is there any way to make a paged ScrollView in SwiftUI?
Question: How can I scroll in the UIScrollView to a position with a button click on a button in a SwiftUI View OR what is also good for my needs to scroll to a position when first displaying the ScrollView
I tried contentOffset but this didnt work. Perhaps I've done something wrong.
ScrollViewWrapper:
class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = false
v.alwaysBounceVertical = true
return v
}()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.pinEdges(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
}
struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
return vc
}
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
}
}
SwiftUI usage:
struct ContentView: View{
#ObservedObject var search = SearchBar()
var body: some View{
NavigationView{
GeometryReader{geo in
UIScrollViewWrapper{ //<-----------------
VStack{
ForEach(0..<10){i in
Text("lskdfj")
}
}
.frame(width: geo.size.width)
}
.navigationBarTitle("Test")
}
}
}
}
We will first declare the offset property in the UIViewControllerRepresentable, with the propertyWrapper #Binding, because its value can be changed by the scrollview or by the parent view (the ContentView).
struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
#Binding var offset: CGPoint
init(offset: Binding<CGPoint>, #ViewBuilder content: #escaping () -> Content) {
self.content = content
_offset = offset
}
// ....//
}
If the offset changes cause of the parent view, we must apply these changes to the scrollView in the updateUIViewController function (which is called when the state of the view changes) :
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(content())
viewController.scrollView.contentOffset = offset
}
When the offset changes because the user scrolls, we must reflect this change on our Binding. To do this we must declare a Coordinator, which will be a UIScrollViewDelegate, and modify the offset in its scrollViewDidScroll function :
class Controller: NSObject, UIScrollViewDelegate {
var parent: UIScrollViewWrapper<Content>
init(parent: UIScrollViewWrapper<Content>) {
self.parent = parent
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
parent.offset = scrollView.contentOffset
}
}
and, in struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable
func makeCoordinator() -> Controller {
return Controller(parent: self)
}
Finally, for the initial offset (this is important otherwise your starting offset will always be 0), this happens in the makeUIViewController:
you have to add these lines:
vc.view.layoutIfNeeded ()
vc.scrollView.contentOffset = offset
The final project :
import SwiftUI
struct ContentView: View {
#State private var offset: CGPoint = CGPoint(x: 0, y: 200)
let texts: [String] = (1...100).map {_ in String.random(length: Int.random(in: 6...20))}
var body: some View {
ZStack(alignment: .top) {
GeometryReader { geo in
UIScrollViewWrapper(offset: $offset) { //
VStack {
Text("Start")
.foregroundColor(.red)
ForEach(texts, id: \.self) { text in
Text(text)
}
}
.padding(.top, 40)
.frame(width: geo.size.width)
}
.navigationBarTitle("Test")
}
HStack {
Text(offset.debugDescription)
Button("add") {
offset.y += 100
}
}
.padding(.bottom, 10)
.frame(maxWidth: .infinity)
.background(Color.white)
}
}
}
class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = false
v.alwaysBounceVertical = true
return v
}()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(scrollView)
pinEdges(of: scrollView, to: view)
hostingController.willMove(toParent: self)
scrollView.addSubview(hostingController.view)
pinEdges(of: hostingController.view, to: scrollView)
hostingController.didMove(toParent: self)
}
func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
}
struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
#Binding var offset: CGPoint
init(offset: Binding<CGPoint>, #ViewBuilder content: #escaping () -> Content) {
self.content = content
_offset = offset
}
func makeCoordinator() -> Controller {
return Controller(parent: self)
}
func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.scrollView.contentInsetAdjustmentBehavior = .never
vc.hostingController.rootView = AnyView(content())
vc.view.layoutIfNeeded()
vc.scrollView.contentOffset = offset
vc.scrollView.delegate = context.coordinator
return vc
}
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(content())
viewController.scrollView.contentOffset = offset
}
class Controller: NSObject, UIScrollViewDelegate {
var parent: UIScrollViewWrapper<Content>
init(parent: UIScrollViewWrapper<Content>) {
self.parent = parent
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
parent.offset = scrollView.contentOffset
}
}
}
You will need to pass a #Binding var offset: CGPoint into the UIScrollViewWrapper then when the button is clicked in your SwiftUI view, you can update the binding value which can then be used in the update method for UIViewControllerRepresentable. Another idea is to use UIViewRepresentable instead and use that with UIScrollView. Here is a helpful article doing that and setting its offset: https://www.fivestars.blog/articles/scrollview-offset/.

Having user add multiple Images to SwiftUI view

I am practicing with SwiftUI and making a meme maker which has labels that are produced from a textField and can be moved and resized. I also want to be able to do this with images from the users Photo library. I am able to get one image, but if I try and get more it just replaces the first image. I tried having the images added to an array, but then the images will not show up on the memeImageView.
Image property
#State private var image = UIImage()
Button
Button {
self.isShowPhotoLibrary = true
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
MemeUmageView
var memeImageView: some View {
ZStack {
KFImage(URL(string: meme.url ?? ""))
.placeholder {
ProgressView()
}
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: UIScreen.main.bounds.height / 2.5)
ForEach(addedLabels, id:\.self) { label in
DraggableLabel(text: label)
}
DraggableImage(image: image)
}
.clipped()
}
Attempt with using an array. I also tried making three buttons to add up to three images, each as its own property thinking that the initial property was being overridden.
My image array
#State private var addedImages = [UIImage?]()
Button
Button {
self.isShowPhotoLibrary = true
addedImages.append(image)
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
var memeImageView: some View {
ZStack {
KFImage(URL(string: meme.url ?? ""))
.placeholder {
ProgressView()
}
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: UIScreen.main.bounds.height / 2.5)
ForEach(addedLabels, id:\.self) { label in
DraggableLabel(text: label)
}
ForEach(0..<addedImages.count) { index in
DraggableImage(image: addedImages[index]!)
}
}
.clipped()
}
Where I call MemeImageView.
var body: some View {
VStack(spacing: 12) {
memeImageView
ForEach(0..<(meme.boxCount ?? 0)) { i in
TextField("Statement \(i + 1)", text: $addedLabels[i])
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.25))
.cornerRadius(5)
.onTapGesture {
self.endEditing()
}
}
.padding(.horizontal)
}.onTapGesture {
self.endEditing()
}
// Gets a new Image
Button {
self.isShowPhotoLibrary = true
addedImages.append(image)
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
Spacer()
// Saves Image
Button {
// takes a screenshot and crops it
if let image = memeImageView.takeScreenshot(origin: CGPoint(x: 0, y: UIApplication.shared.windows[0].safeAreaInsets.top + navBarHeight + 1), size: CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height / 2.5)) {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
presentationMode.wrappedValue.dismiss() // dismisses the view
}
}
label: {
Text("Save image")
.foregroundColor(Color.yellow)
}.frame( width: 150, height: 50)
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.red, lineWidth: 3)
)
.navigationBarTitle(meme.name ?? "Meme", displayMode: .inline)
.background(NavBarAccessor { navBar in
self.navBarHeight = navBar.bounds.height
})
}
For Reproducing(as close to how mine actual project is setup):
Content View
import SwiftUI
struct ContentView: View {
var body: some View {
DragImageView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
DragImageView:
import SwiftUI
struct DragImageView: View {
//===================
// MARK: Properties
//===================
#State private var addedImages = [UIImage?]()
#State private var isShowPhotoLibrary = false
#State private var image = UIImage()
var body: some View {
VStack(spacing: 12) {
imageView
}
// Gets a new Image
Button {
self.isShowPhotoLibrary = true
addedImages.append(image)
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
Spacer()
}
var imageView: some View {
ZStack {
DraggableImage(image: image)
}
//.clipped()
}
// This will dismiss the keyboard
private func endEditing() {
UIApplication.shared.endEditing()
}
}
// Allows fot the keyboard to be dismissed
extension UIApplication {
func endEditing() {
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
DraggableImage:
import SwiftUI
struct DraggableImage: View {
// Drag Gesture
#State private var currentPosition: CGSize = .zero
#State private var newPosition: CGSize = .zero
// Roation Gesture
#State private var rotation: Double = 0.0
// Scale Gesture
#State private var scale: CGFloat = 1.0
// The different states the frame of the label could be
private enum WidthState: Int {
case full, half, third, fourth
}
#State private var widthState: WidthState = .full
#State private var currentWidth: CGFloat = 100 //UIScreen.main.bounds.width
var image: UIImage
var body: some View {
VStack {
Image(uiImage: self.image)
.resizable()
.scaledToFill()
.frame(width: self.currentWidth)
.lineLimit(nil)
}
.scaleEffect(scale) // Scale based on our state
.rotationEffect(Angle.degrees(rotation)) // Rotate based on the state
.offset(x: self.currentPosition.width, // Offset from the drag difference from it's current position
y: self.currentPosition.height)
.gesture(
// Two finger rotation
RotationGesture()
.onChanged { angle in
self.rotation = angle.degrees // keep track of the angle for state
}
// We want it to work with the scale effect, so they could either scale and rotate at the same time
.simultaneously(with:
MagnificationGesture()
.onChanged { scale in
self.scale = scale.magnitude // Keep track of the scale
})
// Update the drags new position to be wherever it was last dragged to. (we don't want to reset it back to it's current position)
.simultaneously(with: DragGesture()
.onChanged { value in
self.currentPosition = CGSize(width: value.translation.width + self.newPosition.width,
height: value.translation.height + self.newPosition.height)
}
.onEnded { value in
self.newPosition = self.currentPosition
})
)
/// Have to do double tap first or else it will never work with the single tap
.onTapGesture(count: 2) {
// Update our widthState to be the next on in the 'enum', or start back at .full
self.widthState = WidthState(rawValue: self.widthState.rawValue + 1) ?? .full
self.currentWidth = UIScreen.main.bounds.width / CGFloat(self.widthState.rawValue)
}
}
}
ImagePicker:
import UIKit
import SwiftUI
struct ImagePicker: UIViewControllerRepresentable {
var sourceType: UIImagePickerController.SourceType = .photoLibrary
#Binding var selectedImage: UIImage
#Environment(\.presentationMode) private var presentationMode
func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
let imagePicker = UIImagePickerController()
imagePicker.allowsEditing = false
imagePicker.sourceType = sourceType
imagePicker.delegate = context.coordinator
return imagePicker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
var parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
parent.selectedImage = image
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}
I should add this is to make memes, so the user picked images go on top the view that I save to the camera roll.
I'm not 100% clear on what the exact desired output should be, but this should get you started (explained below):
struct DragImageView: View {
//===================
// MARK: Properties
//===================
#State private var addedImages = [UIImage]()
#State private var isShowPhotoLibrary = false
var bindingForImage: Binding<UIImage> {
Binding<UIImage> { () -> UIImage in
return addedImages.last ?? UIImage()
} set: { (newImage) in
addedImages.append(newImage)
print("Images: \(addedImages.count)")
}
}
var body: some View {
VStack(spacing: 12) {
imageView
}
// Gets a new Image
Button {
self.isShowPhotoLibrary = true
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: bindingForImage)
}
Spacer()
}
var imageView: some View {
VStack {
ForEach(addedImages, id: \.self) { image in
DraggableImage(image: image)
}
}
}
// This will dismiss the keyboard
private func endEditing() {
UIApplication.shared.endEditing()
}
}
addedImages is now an array of non-optional UIImages
There's a custom Binding for the image picker. When it receives a new image, it appends it to the end of the array.
In var imageView, there's a VStack instead of a ZStack so that multiple images can get displayed (instead of stacked on top of each other) and a ForEach loop to iterate through the images.

How can I read Drag location in ScrollView in SwiftUI?

I am trying to read location in DragGesture of a View connected to a ScrollView, but ScrollView take control of DragGesture on all view and does not let me to read those information.
Here is my example to show the issue,
My Goal is reading the Drag location in DragGesture.
using new code:
struct ContentView: View {
#State var locationY: CGFloat = .zero
var body: some View {
VStack
{
HStack
{
VStack
{
Color.black.frame(height: 300)
}
.gesture( DragGesture().onChanged { value in locationY = value.location.y; print(locationY)} )
ScrollView
{
VStack
{
Color.blue.frame(height: 300)
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: $0.frame(in: .global).origin.y)
// value: $0.frame(in: .named("scroll_area")).origin.y)
})
}
// .coordinateSpace(name: "scroll_area")
.onPreferenceChange(ViewOffsetKey.self) {
self.locationY = $0
print(">> offset: \($0)")
}
.frame(height: 300)
}
Spacer()
}
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
Here is possible solution, to use GeometryReader in background to read rect of view (in any needed coordinate space) and view preferences to pass needed coordinate at top level.
Tested with Xcode 12.1 / iOS 14.1
Note: commented variant is for superview (ie. ScrollView) coordinates
ScrollView
{
VStack
{
Color.blue.frame(height: 300)
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: $0.frame(in: .global).origin.y)
// value: $0.frame(in: .named("scroll_area")).origin.y)
})
}
// .coordinateSpace(name: "scroll_area")
.onPreferenceChange(ViewOffsetKey.self) {
self.locationY = $0
print(">> offset: \($0)")
}
and helper preference key
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}

SwiftUI doesn't update state to #ObservedObject cameraViewModel object

I'm new to SwiftUI and manual camera functionality, and I really need help.
So I trying to build a SwiftUI camera view that has a UIKit camera as a wrapper to control the focus lens position via SwiftUI picker view, display below a fucus value, and want to try have a correlation between AVcaptureDevice.lensPosition from 0 to 1.0 and feats that are displayed in the focus picker view. But for now, I only want to display that fucus number on screen.
And the problem is when I try to update focus via coordinator focus observation and set it to the camera view model then nothing happened. Please help 🙌
Here's the code:
import SwiftUI
import AVFoundation
import Combine
struct ContentView: View {
#State private var didTapCapture = false
#State private var focusLensPosition: Float = 0
#ObservedObject var cameraViewModel = CameraViewModel(focusLensPosition: 0)
var body: some View {
VStack {
ZStack {
CameraPreviewRepresentable(didTapCapture: $didTapCapture, cameraViewModel: cameraViewModel)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
VStack {
FocusPicker(selectedFocus: $focusLensPosition)
Text(String(cameraViewModel.focusLensPosition))
.foregroundColor(.red)
.font(.largeTitle)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.edgesIgnoringSafeArea(.all)
Spacer()
CaptureButton(didTapCapture: $didTapCapture)
.frame(width: 100, height: 100, alignment: .center)
.padding(.bottom, 20)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct CaptureButton: View {
#Binding var didTapCapture : Bool
var body: some View {
Button {
didTapCapture.toggle()
} label: {
Image(systemName: "photo")
.font(.largeTitle)
.padding(30)
.background(Color.red)
.foregroundColor(.white)
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color.red)
)
}
}
}
struct CameraPreviewRepresentable: UIViewControllerRepresentable {
#Environment(\.presentationMode) var presentationMode
#Binding var didTapCapture: Bool
#ObservedObject var cameraViewModel: CameraViewModel
let cameraController: CustomCameraController = CustomCameraController()
func makeUIViewController(context: Context) -> CustomCameraController {
cameraController.delegate = context.coordinator
return cameraController
}
func updateUIViewController(_ cameraViewController: CustomCameraController, context: Context) {
if (self.didTapCapture) {
cameraViewController.didTapRecord()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self, cameraViewModel: cameraViewModel)
}
class Coordinator: NSObject, UINavigationControllerDelegate, AVCapturePhotoCaptureDelegate {
let parent: CameraPreviewRepresentable
var cameraViewModel: CameraViewModel
var focusLensPositionObserver: NSKeyValueObservation?
init(_ parent: CameraPreviewRepresentable, cameraViewModel: CameraViewModel) {
self.parent = parent
self.cameraViewModel = cameraViewModel
super.init()
focusLensPositionObserver = self.parent.cameraController.currentCamera?.observe(\.lensPosition, options: [.new]) { [weak self] camera, _ in
print(Float(camera.lensPosition))
//announcing changes via Publisher
self?.cameraViewModel.focusLensPosition = camera.lensPosition
}
}
deinit {
focusLensPositionObserver = nil
}
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
parent.didTapCapture = false
if let imageData = photo.fileDataRepresentation(), let image = UIImage(data: imageData) {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}
class CameraViewModel: ObservableObject {
#Published var focusLensPosition: Float = 0
init(focusLensPosition: Float) {
self.focusLensPosition = focusLensPosition
}
}
class CustomCameraController: UIViewController {
var image: UIImage?
var captureSession = AVCaptureSession()
var backCamera: AVCaptureDevice?
var frontCamera: AVCaptureDevice?
var currentCamera: AVCaptureDevice?
var photoOutput: AVCapturePhotoOutput?
var cameraPreviewLayer: AVCaptureVideoPreviewLayer?
//DELEGATE
var delegate: AVCapturePhotoCaptureDelegate?
func showFocusLensPosition() -> Float {
// guard let camera = currentCamera else { return 0 }
// try! currentCamera!.lockForConfiguration()
// currentCamera!.focusMode = .autoFocus
//// currentCamera!.setFocusModeLocked(lensPosition: currentCamera!.lensPosition, completionHandler: nil)
// currentCamera!.unlockForConfiguration()
return currentCamera!.lensPosition
}
func didTapRecord() {
let settings = AVCapturePhotoSettings()
photoOutput?.capturePhoto(with: settings, delegate: delegate!)
}
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
func setup() {
setupCaptureSession()
setupDevice()
setupInputOutput()
setupPreviewLayer()
startRunningCaptureSession()
}
func setupCaptureSession() {
captureSession.sessionPreset = .photo
}
func setupDevice() {
let deviceDiscoverySession =
AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera],
mediaType: .video,
position: .unspecified)
for device in deviceDiscoverySession.devices {
switch device.position {
case .front:
self.frontCamera = device
case .back:
self.backCamera = device
default:
break
}
}
self.currentCamera = self.backCamera
}
func setupInputOutput() {
do {
let captureDeviceInput = try AVCaptureDeviceInput(device: currentCamera!)
captureSession.addInput(captureDeviceInput)
photoOutput = AVCapturePhotoOutput()
captureSession.addOutput(photoOutput!)
} catch {
print(error)
}
}
func setupPreviewLayer() {
self.cameraPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
self.cameraPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
let deviceOrientation = UIDevice.current.orientation
cameraPreviewLayer?.connection?.videoOrientation = AVCaptureVideoOrientation(rawValue: deviceOrientation.rawValue)!
self.cameraPreviewLayer?.frame = self.view.frame
// view.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
self.view.layer.insertSublayer(cameraPreviewLayer!, at: 0)
}
func startRunningCaptureSession() {
captureSession.startRunning()
}
}
struct FocusPicker: View {
var feets = ["∞ ft", "30", "15", "10", "7", "5", "4", "3.5", "3", "2.5", "2", "1.5", "1", "0.5", "Auto"]
#Binding var selectedFocus: Float
var body: some View {
Picker(selection: $selectedFocus, label: Text("")) {
ForEach(0 ..< feets.count) {
Text(feets[$0])
.foregroundColor(.white)
.font(.subheadline)
.fontWeight(.medium)
}
.animation(.none)
.background(Color.clear)
.pickerStyle(WheelPickerStyle())
}
.frame(width: 60, height: 200)
.border(Color.gray, width: 5)
.clipped()
}
}
The problem with your provided code is that the type of selectedFocus within the FocusPicker view should be Integer rather than Float. So one option is to change this type to Integer and find a way to express the AVCaptureDevice.lensPosition as an Integer with the given range.
The second option is to replace the feets array with an enumeration. By making the enumeration conform to the CustomStringConvertible protocol, you can even provide a proper description. Please see my example below.
I've stripped your code a bit as you just wanted to display the number in the first step and thus the code is more comprehensible.
My working example:
import SwiftUI
import Combine
struct ContentView: View {
#ObservedObject var cameraViewModel = CameraViewModel(focusLensPosition: 0.5)
var body: some View {
VStack {
ZStack {
VStack {
FocusPicker(selectedFocus: $cameraViewModel.focusLensPosition)
Text(String(self.cameraViewModel.focusLensPosition))
.foregroundColor(.red)
.font(.largeTitle)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.edgesIgnoringSafeArea(.all)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
class CameraViewModel: ObservableObject {
#Published var focusLensPosition: Float
init(focusLensPosition: Float) {
self.focusLensPosition = focusLensPosition
}
}
enum Feets: Float, CustomStringConvertible, CaseIterable, Identifiable {
case case1 = 0.0
case case2 = 0.5
case case3 = 1.0
var id: Float { self.rawValue }
var description: String {
get {
switch self {
case .case1:
return "∞ ft"
case .case2:
return "4"
case .case3:
return "Auto"
}
}
}
}
struct FocusPicker: View {
#Binding var selectedFocus: Float
var body: some View {
Picker(selection: $selectedFocus, label: Text("")) {
ForEach(Feets.allCases) { feet in
Text(feet.description)
}
.animation(.none)
.background(Color.clear)
.pickerStyle(WheelPickerStyle())
}
.frame(width: 60, height: 200)
.border(Color.gray, width: 5)
.clipped()
}
}