SwiftUI: How to let ScrollView move one item once - uiscrollview

My image list is a horizontal scroll list. And image in the list is presented as full screen width. By default, it is moving continuously, however, I want to let it move one item at a time, how could I do that.
I search it here, there is some similar questions related with Android but not iOS, specifically SwiftUI.
private struct PresentedImageList: View {
var images: [ImageViewModel]
var body: some View {
GeometryReader { gr in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 6) {
ForEach(self.images, id: \.self) { image in
KFImage(source: .network(image.fileURL))
.resizable()
.frame(width: gr.size.width)
.aspectRatio(1.77, contentMode: .fit)
}
}
}.background(Color.black)
}
}
}

If you have one ScrollView appearing, then you could just simply add
.onAppear {
UIScrollView.appearance().isPagingEnabled = true
}
this code at the end of the ScrollView Closure.
entire code would be
private struct PresentedImageList: View {
var images: [ImageViewModel]
var body: some View {
GeometryReader { gr in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 6) {
ForEach(self.images, id: \.self) { image in
KFImage(source: .network(image.fileURL))
.resizable()
.frame(width: gr.size.width)
.aspectRatio(1.77, contentMode: .fit)
}
}
}
.onAppear {
UIScrollView.appearance().isPagingEnabled = true
}
}
}
}
However if you have multiple ScrollView appearing, that added code will affect all of the ScrollViews appearing.
So in that case you could use TabView, instead of HStackView.
var body: some View {
GeometryReader { gr in
ScrollView(.horizontal, showsIndicators: false) {
TabView(alignment: .top, spacing: 6) {
ForEach(self.images, id: \.self) { image in
KFImage(source: .network(image.fileURL))
.resizable()
.frame(width: gr.size.width)
.aspectRatio(1.77, contentMode: .fit)
}
}
//.tabViewStyle(PageTabViewStyle.page(indexDisplayMode: .never))
// --> in case you don't want the indicators
}
}
}

in SwiftUI this is not available. so need to do using UIKit
struct ContentView: View {
var images: [ImageViewModel]
var body: some View {
GeometryReader { gr in
ScrollViewUI(hideScrollIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.images, id: \.self) { image in
VStack {
KFImage(source: .network(image.fileURL))
.resizable()
.frame(width: gr.size.width-6,alignment: .center)
.aspectRatio(1.77, contentMode: .fit)
}
.frame(width: gr.size.width,alignment: .center)
}
}
}
}
}
}
ScrollViewUI
struct ScrollViewUI<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
var hideScrollIndicators: Bool = false
init(hideScrollIndicators: Bool, #ViewBuilder content: #escaping () -> Content) {
self.content = content
self.hideScrollIndicators = hideScrollIndicators
}
func makeUIViewController(context: Context) -> ScrollViewController<Content> {
let vc = ScrollViewController(rootView: self.content())
vc.hideScrollIndicators = hideScrollIndicators
return vc
}
func updateUIViewController(_ viewController: ScrollViewController<Content>, context: Context) {
viewController.hostingController.rootView = self.content()
}
}
ScrollViewController
class ScrollViewController<Content: View>: UIViewController, UIScrollViewDelegate {
var hideScrollIndicators: Bool = false
lazy var scrollView: UIScrollView = {
let view = UIScrollView()
view.delegate = self
view.showsVerticalScrollIndicator = !hideScrollIndicators
view.showsHorizontalScrollIndicator = !hideScrollIndicators
view.bounces = false
view.backgroundColor = .clear
view.isPagingEnabled = true
return view
}()
init(rootView: Content) {
self.hostingController = UIHostingController<Content>(rootView: rootView)
self.hostingController.view.backgroundColor = .clear
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var hostingController: UIHostingController<Content>! = nil
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(scrollView)
view.backgroundColor = .clear
self.makefullScreen(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.makefullScreen(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func makefullScreen(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),
])
}
}

Related

Tracking scroll position in a List SwiftUI

So in the past I have been tracking the scroll position using a scroll view but I've fallen into a situation where I need to track the position using a List. I am using a List because I want some of the built in real estate to create my views such as the default List styles.
I can get the value using PreferenceKeys, but the issue is when I scroll to far upwards, the PreferenceKey value will default back to its position 0, breaking my show shy header view logic.
This is the TrackableListView code
struct TrackableListView<Content: View>: View {
let offsetChanged: (CGPoint) -> Void
let content: Content
init(offsetChanged: #escaping (CGPoint) -> Void = { _ in }, #ViewBuilder content: () -> Content) {
self.offsetChanged = offsetChanged
self.content = content()
}
var body: some View {
List {
GeometryReader { geometry in
Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("ListView")).origin)
}
.frame(width: 0, height: 0)
content
.offset(y: -10)
}
.coordinateSpace(name: "ListView")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: offsetChanged)
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}
And this is my ContentView:
struct ContentView: View {
#State private var contentOffset = CGFloat(0)
#State private var offsetPositionValue: CGFloat = 0
#State private var isShyHeaderVisible = false
var body: some View {
NavigationView {
ZStack {
TrackableListView { offset in
withAnimation {
contentOffset = offset.y
}
} content: {
Text("\(contentOffset)")
}
.overlay(
ZStack {
HStack {
Text("Total points")
.foregroundColor(.white)
.lineLimit(1)
Spacer()
Text("20,000 pts")
.foregroundColor(.white)
.padding(.leading, 50)
}
.padding(.horizontal)
.padding(.vertical, 8)
.frame(width: UIScreen.main.bounds.width)
.background(Color.green)
.offset(y: contentOffset < 50 ? 0 : -5)
.opacity(contentOffset < 50 ? 1 : 0)
.transition(.move(edge: .top))
}
.frame(maxHeight: .infinity, alignment: .top)
)
}
.navigationTitle("Hello")
.navigationViewStyle(StackNavigationViewStyle())
.navigationBarTitleDisplayMode(.inline)
.frame(maxHeight: .infinity, alignment: .top)
.background(AccountBackground())
}
}
}
The issue was that Other Views in my hierarchy (probably introduced by SwiftUI) could be sending the default value, which is why you start getting zero sometimes.
What I needed was a way to determine when to use or forward the new value, versus when to ignore it.
To do this I had to make my value optional GCPoint, with a nil default value, then in your reduce method when using PreferenceKeys you have to do:
if let nextValue = nextValue() {
value = nextValue
}
Then make sure your CGPoint is an optional value.

SwiftUI / imagepicker / firebasefirestore - image uploads properly but does not refresh when leaving image picker view

I have this settingview where the user can update their profile picture, which gets uploaded in Firebase Storage, and gets loaded as WebImage(url:ImageURL). It works, however when the user changes the profile image, it does not get immediately refreshed. They have to leave the screen and come back, then the loadImageFromFirebase function gets called and the new image is displayed properly.
I would like the new image to show as soon as it is selected and uploaded to Firebase Storage.
I have tried SDImageCache.shared.clearMemory() but that didn't really help (perhaps I didn't do it in the right place?)
Here is my code:
import SwiftUI
import FirebaseFirestore
import Firebase
import SDWebImageSwiftUI
struct SettingsView: View {
#ObservedObject var settingsViewModel = SettingsViewModel()
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#State private var presented = false
#State private var isShowPhotoLibrary = false
#State private var image = UIImage()
#State private var imageURL = URL(string: "")
var body: some View {
NavigationView {
VStack {
WebImage(url: imageURL)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 250.0, height: 250.0, alignment: .center)
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
.scaledToFit()
Text("Project")
.font(.title)
.fontWeight(.semibold)
Form {
Section {
HStack {
Image(systemName: "checkmark.circle")
Text("Upload profile image")
Spacer()
Text("Plain")
.onTapGesture {
self.isShowPhotoLibrary = true
}
.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
}
}
AccountSection(settingsViewModel: self.settingsViewModel)
}
.navigationBarTitle("Settings", displayMode: .inline)
.navigationBarItems(trailing:
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Text("Done")
})
}
.onAppear(perform: loadImageFromFirebase)
}
}
func loadImageFromFirebase() {
let storage = Storage.storage()
guard let userid = Auth.auth().currentUser?.uid else { return }
let storageRef = storage.reference().child(userid+"/profilephoto"+"/profile.jpeg")
storageRef.downloadURL {
(url, error) in
if error != nil {
print((error?.localizedDescription)!)
return
}
self.imageURL = url!
}
}
}
and the image picker:
import UIKit
import SwiftUI
import SDWebImageSwiftUI
import Firebase
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
uploadprofileImage(image: image)
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}

SwiftUI / imagepicker / firebasefirestore - image uploads properly but does not show on the imageview

I'm working on this project where a user can go on a setting view and pick an image (using imagepicker) as a profile image. The image is then uploaded on Firebasefirestore, and should be displayed in a circle on the same screen. The image picker works and the selected image gets uploaded properly on firebasefirestore, however the selected image does not appear properly in the circle. Can you help me out?
Here's the relevant part of my code. Happy to share the code of the imagepicker if necessary.
import SwiftUI
struct SettingsView: View {
#ObservedObject var settingsViewModel = SettingsViewModel()
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#State private var presented = false
#State private var isShowPhotoLibrary = false
#State private var image = UIImage()
var body: some View {
NavigationView {
VStack {
Image(uiImage:self.image) // <-- here is where I want the image to appear
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 250.0, height: 250.0, alignment: .center)
.clipShape(/*#START_MENU_TOKEN#*/Circle()/*#END_MENU_TOKEN#*/)
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
.scaledToFit()
Text("Project")
.font(.title)
.fontWeight(.semibold)
Form {
Section {
HStack {
Image(systemName: "checkmark.circle")
Text("Upload profile image")
Spacer()
Text("Plain")
.onTapGesture {
self.isShowPhotoLibrary = true
}
.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
}
}
AccountSection(settingsViewModel: self.settingsViewModel)
}
.navigationBarTitle("Settings", displayMode: .inline)
.navigationBarItems(trailing:
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Text("Done")
})
}
}
}
}
image picker:
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 {
uploadprofileImage(image: image)
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}
You never set #Binding var selectedImage, so the Image back in the SettingsView doesn't update.
if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
parent.selectedImage = image /// add this line!
uploadprofileImage(image: image)
}

Animating a View by its height in SwiftUI

I am attempting to make a view which will animate another content view in from the bottom of the screen. The below code works, however, as the content view will have unknown height the 200 offset may not be correct. How can I get the height of the content in order to offset the view correctly?
struct Test<Content>: View where Content : View {
#State var showing: Bool = false
var content: Content
var body: some View {
VStack {
Button(action: {
withAnimation {
self.showing.toggle()
}
}) {
Text("Toggle")
}
Spacer()
HStack {
Spacer()
content
Spacer()
}
.background(Color.red)
.padding(10)
.offset(y: showing ? 200 : 0)
}
}
}
Here is possible approach to read content height directly from it during alignment...
struct Test<Content>: View where Content : View {
var content: Content
#State private var showing: Bool = false
#State private var contentHeight: CGFloat = .zero
var body: some View {
VStack {
Button(action: {
withAnimation {
self.showing.toggle()
}
}) {
Text("Toggle")
}
Spacer()
HStack {
Spacer()
content
.alignmentGuide(VerticalAlignment.center) { d in
DispatchQueue.main.async {
self.contentHeight = d.height
}
return d[VerticalAlignment.center]
}
Spacer()
}
.background(Color.red)
.padding(10)
.offset(y: showing ? contentHeight : 0)
}
}
}

SwiftUI: Custom Modal Animation

I made a custom modal using SwiftUI. It works fine, but the animation is wonky.
When played in slow motion, you can see that the ModalContent's background disappears immediately after triggering ModalOverlay's tap action. However, ModalContent's Text views stay visible the entire time.
Can anyone tell me how I can prevent ModalContent's background from prematurely disappearing?
Slow-mo video and code below:
import SwiftUI
struct ContentView: View {
#State private var isShowingModal = false
var body: some View {
GeometryReader { geometry in
ZStack {
Button(
action: { withAnimation { self.isShowingModal = true } },
label: { Text("Show Modal") }
)
ZStack {
if self.isShowingModal {
ModalOverlay(tapAction: { withAnimation { self.isShowingModal = false } })
ModalContent().transition(.move(edge: .bottom))
}
}.edgesIgnoringSafeArea(.all)
}
}
}
}
struct ModalOverlay: View {
var color = Color.black.opacity(0.4)
var tapAction: (() -> Void)? = nil
var body: some View {
color.onTapGesture { self.tapAction?() }
}
}
struct ModalContent: View {
var body: some View {
GeometryReader { geometry in
VStack {
Spacer()
VStack(spacing: 16) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
.frame(width: geometry.size.width)
.padding(.top, 16)
.padding(.bottom, geometry.safeAreaInsets.bottom)
.background(Color.white)
}
}
}
}
The solution (thanks to #JWK):
It's probably a bug. It seems that, during the transition animation (when the views are disappearing) the zIndex of the two views involved (the ModalContent and the ModalOverlay) is not respected. The ModalContent (that is supposed to be in front of the ModalOverlay) is actually moved under the ModalOverlay at the beginning of the animation. To fix this we can manually set the zIndex to, for example, 1 on the ModalContent view.
struct ContentView: View {
#State private var isShowingModal = false
var body: some View {
GeometryReader { geometry in
ZStack {
Button(
action: { withAnimation { self.isShowingModal = true } },
label: { Text("Show Modal") }
)
ZStack {
if self.isShowingModal {
ModalOverlay(tapAction: { withAnimation(.easeOut(duration: 5)) { self.isShowingModal = false } })
ModalContent()
.transition(.move(edge: .bottom))
.zIndex(1)
}
}.edgesIgnoringSafeArea(.all)
}
}
}
}
The investigation that brings to a solution
Transition animations in SwiftUI have still some issues. I think this is a bug. I'm quite sure because:
1) Have you tried to change the background color of your ModalContent from white to green?
struct ModalContent: View {
var body: some View {
GeometryReader { geometry in
VStack {
Spacer()
VStack(spacing: 16) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
.frame(width: geometry.size.width)
.padding(.top, 16)
.padding(.bottom, geometry.safeAreaInsets.bottom)
.background(Color.green)
}
}
}
}
This way it works (see the following GIF):
2) Another way to make the bug occur is to change the background color of your ContentView to, for example, green, leaving the ModalContent to white:
struct ContentView: View {
#State private var isShowingModal = false
var body: some View {
GeometryReader { geometry in
ZStack {
Button(
action: { withAnimation(.easeOut(duration: 5)) { self.isShowingModal = true } },
label: { Text("Show Modal") }
)
ZStack {
if self.isShowingModal {
ModalOverlay(tapAction: { withAnimation(.easeOut(duration: 5)) { self.isShowingModal = false } })
ModalContent().transition(.move(edge: .bottom))
}
}
}
}
.background(Color.green)
.edgesIgnoringSafeArea(.all)
}
}
struct ModalOverlay: View {
var color = Color.black.opacity(0.4)
var tapAction: (() -> Void)? = nil
var body: some View {
color.onTapGesture { self.tapAction?() }
}
}
struct ModalContent: View {
var body: some View {
GeometryReader { geometry in
VStack {
Spacer()
VStack(spacing: 16) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
.frame(width: geometry.size.width)
.padding(.top, 16)
.padding(.bottom, geometry.safeAreaInsets.bottom)
.background(Color.white)
}
}
}
}
Even in this case it works as expected:
3) But if you change your ModalContent background color to green (so you have both the ContentView and the ModalContent green), the problem occurs again (I won't post another GIF but you can easily try it yourself).
4) Yet another example: if you change the appearance of you iPhone to Dark Appearance (the new feature of iOS 13) your ContentView will automatically become black and, since your ModalView is white, the problem won't occur and everything goes fine.

Resources