Using iOS14.0.1, Swift5.3, Xcode12.0.1,
I am desperate with this problem:
With iOS12 and iOS13, my App was working nicely.
But now with iOS14, no UIButton, no UISwitch, no segue - nothing is working in my Modal ViewController.
I have no idea what Apple changed in UIKit for this problem to occur ???? !!!!
Please help!
My Modal ViewController is presented as follows:
#objc func settingsBtnPressed(_ sender: UIButton) {
let settingsVC = SettingsViewController()
settingsVC.backDelegate = self
let navController = UINavigationController(rootViewController: settingsVC)
navController.navigationBar.barStyle = .black
navController.navigationBar.tintColor = .white
navController.navigationBar.prefersLargeTitles = true
navController.navigationBar.backgroundColor = UIColor.clear
navController.presentationController?.delegate = self
self.present(navController, animated: true)
}
class SettingsViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
let cellId = "cellID"
init() {
let collectionViewFlowLayout = UICollectionViewFlowLayout()
collectionViewFlowLayout.estimatedItemSize = CGSize(width: UIScreen.main.bounds.width, height: 1)
super.init(collectionViewLayout: collectionViewFlowLayout)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
// ...
collectionView.register(SettingsCollectionViewCell.self, forCellWithReuseIdentifier: cellId)
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! SettingsCollectionViewCell
cell.setup(titlesFirstSection: titlesFirstSection, iconsFirstSection: iconsFirstSection, onStatesFirstSection: onStatesFirstSection, )
return cell
}
}
class SettingsCollectionViewCell: UICollectionViewCell {
var SettingsStackView = UIStackView()
var attries: UICollectionViewLayoutAttributes!
var myContentHeight: CGFloat = 0.0
var firstSettingsSectionView: MenuSwitchListSectionView?
let elementHeight: CGFloat = 44.0
let separatorLineThickness: CGFloat = 1.0
let sectionViewBackgroundColor = ImageConverter.UIColorFromRGB(0x2C2C2E, alpha: 1.0)
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup(titlesFirstSection: [String],
iconsFirstSection: [UIImage?]?,
onStatesFirstSection: [Bool]) {
myContentHeight = UIScreen.main.bounds.height - 120
initFirstSettingsSectionView(titles: titlesFirstSection, icons: iconsFirstSection, onStates: onStatesFirstSection)
SettingsStackView = VerticalStackView(arrangedSubviews: [
UIView(),
firstSettingsSectionView! as UIView,
UIView()
], spacing: 10.0, alignment: .center )
contentView.addSubview(SettingsStackView)
contentView.isUserInteractionEnabled = true
setConstraints(nrOfItemsFirstSection: titlesFirstSection.count)
}
fileprivate func initFirstSettingsSectionView(titles: [String], icons: [UIImage?]?, onStates: [Bool]) {
firstSettingsSectionView = MenuSwitchListSectionView(frame: .zero,
elementHeight: elementHeight,
separatorLineThickness: separatorLineThickness,
backgroundColor: sectionViewBackgroundColor,
titles: titles,
icons: icons,
onStates: onStates
)
firstSettingsSectionView?.switchMutatedDelegate = self
firstSettingsSectionView?.assignDelegate()
}
fileprivate func setConstraints(nrOfItemsFirstSection: Int) {
contentView.translatesAutoresizingMaskIntoConstraints = false
SettingsStackView.translatesAutoresizingMaskIntoConstraints = false
firstSettingsSectionView?.translatesAutoresizingMaskIntoConstraints = false
SettingsStackView.anchor(top: safeAreaLayoutGuide.topAnchor, leading: contentView.leadingAnchor, bottom: nil, trailing: contentView.trailingAnchor)
let totalHeightFirstSection: CGFloat = elementHeight * CGFloat(nrOfItemsFirstSection) + separatorLineThickness * CGFloat(nrOfItemsFirstSection - 1)
firstSettingsSectionView?.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8.0).isActive = true
firstSettingsSectionView?.heightAnchor.constraint(equalToConstant: totalHeightFirstSection).isActive = true
firstSettingsSectionView?.widthAnchor.constraint(equalToConstant: contentView.bounds.width - 16.0).isActive = true
}
class MenuSwitchElementStackView: UIStackView {
weak var switchMutatedDelegate: SwitchMutatedDelegate?
let imgViewWidth: CGFloat = 23.0
override init(frame: CGRect) {
super.init(frame: frame)
}
convenience init(frame: CGRect, text: String, icon: UIImage?, onState: Bool, switchID: Int) {
self.init(frame: frame)
composeStackView(text: text, icon: icon, onState: onState, switchID: switchID)
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func composeStackView(text: String, icon: UIImage?, onState: Bool, switchID: Int) {
// StackView properties
axis = .horizontal
alignment = .center
spacing = 23.0
var iconImgView: UIImageView?
// create Icon
if let icon = icon {
iconImgView = UIImageView()
iconImgView?.image = icon
iconImgView?.contentMode = .scaleAspectFit
}
// create Title
let titleLabel = UILabel()
titleLabel.font = AppConstants.Font.ListSectionElementTitleFont
titleLabel.text = text
titleLabel.textColor = .white
titleLabel.tintColor = .white
titleLabel.heightAnchor.constraint(equalToConstant: 19.0).isActive = true
// create choice TextLbl
let mySwitch = UISwitch()
mySwitch.tag = switchID
mySwitch.setOn(onState, animated: false)
mySwitch.addTarget(self, action: #selector(MenuSwitchElementStackView.switchChanged(_:)), for: UIControl.Event.valueChanged)
// text stackView creation
if let iconImgView = iconImgView {
addArrangedSubview(iconImgView)
}
addArrangedSubview(titleLabel)
addArrangedSubview(mySwitch)
// set Constraints
setConstraints(iconImgView, titleLabel, mySwitch)
}
#objc func switchChanged(_ mySwitch: UISwitch) {
switchMutatedDelegate?.switchChanged(isOn: mySwitch.isOn, switchID: mySwitch.tag)
}
fileprivate func setConstraints(_ iconImgView: UIImageView?, _ titleLabel: UILabel, _ mySwitch: UISwitch) {
translatesAutoresizingMaskIntoConstraints = false
iconImgView?.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
mySwitch.translatesAutoresizingMaskIntoConstraints = false
iconImgView?.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0).isActive = true
iconImgView?.widthAnchor.constraint(equalToConstant: 24.0).isActive = true
iconImgView?.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
mySwitch.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
mySwitch.leadingAnchor.constraint(equalTo: trailingAnchor, constant: -64.0).isActive = true
}
}
class MenuSwitchListSectionView: UIView {
var sectionElementsStackView: MenuSwitchListSectionWithElementsStackView?
weak var switchMutatedDelegate: SwitchMutatedDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
}
convenience init(frame: CGRect,
elementHeight: CGFloat,
separatorLineThickness: CGFloat,
backgroundColor: UIColor,
titles: [String],
icons: [UIImage?]?,
onStates: [Bool]) {
self.init(frame: frame)
composeView(elementHeight: elementHeight, separatorLineThickness: separatorLineThickness, backgroundColor: backgroundColor, titles: titles, icons: icons, onStates: onStates)
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func assignDelegate() {
self.sectionElementsStackView?.switchMutatedDelegate = switchMutatedDelegate
sectionElementsStackView?.assignDelegate()
}
fileprivate func composeView(elementHeight: CGFloat, separatorLineThickness: CGFloat, backgroundColor: UIColor, titles: [String], icons: [UIImage?]?, onStates: [Bool]) {
// create background View
let totalHeight: CGFloat = elementHeight * CGFloat(titles.count) + separatorLineThickness * CGFloat(titles.count - 1)
let backGroundView = ListSectionBackgroundView(frame: .zero, height: totalHeight, backgroundColor: backgroundColor)
// create stackView
sectionElementsStackView = MenuSwitchListSectionWithElementsStackView(frame: CGRect(x: 0.0, y: 0.0, width: UIScreen.main.bounds.width, height: totalHeight), elementHeight: elementHeight, separatorLineThickness: separatorLineThickness, titles: titles, icons: icons, onStates: onStates)
// add background and stackView to self
addSubview(backGroundView)
addSubview(sectionElementsStackView!)
// set Constraints
translatesAutoresizingMaskIntoConstraints = false
backGroundView.translatesAutoresizingMaskIntoConstraints = false
sectionElementsStackView?.translatesAutoresizingMaskIntoConstraints = false
backGroundView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
backGroundView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
backGroundView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
backGroundView.heightAnchor.constraint(equalToConstant: totalHeight).isActive = true
sectionElementsStackView?.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
sectionElementsStackView?.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16.0).isActive = true
sectionElementsStackView?.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10.0).isActive = true
}
}
class MenuSwitchListSectionWithElementsStackView: UIStackView {
weak var switchMutatedDelegate: SwitchMutatedDelegate?
var elementStackView: MenuSwitchElementStackView?
var elements: [MenuSwitchElementStackView]?
var titles: [String]? // needed for gesture callback
override init(frame: CGRect) {
super.init(frame: frame)
}
convenience init(frame: CGRect,
elementHeight: CGFloat,
separatorLineThickness: CGFloat,
titles: [String],
icons: [UIImage?]?,
onStates: [Bool]) {
self.init(frame: frame)
self.titles = titles // needed for gesture callback
composeStackView(elementHeight: elementHeight, separatorLineThickness: separatorLineThickness, titles: titles, icons: icons, onStates: onStates)
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func assignDelegate() {
if let elements = elements {
for element in elements {
element.switchMutatedDelegate = switchMutatedDelegate
}
}
}
fileprivate func composeStackView(elementHeight: CGFloat, separatorLineThickness: CGFloat, titles: [String], icons: [UIImage?]?, onStates: [Bool]) {
// stackView properties
axis = .vertical
alignment = .leading
spacing = 0.0
// create stackView elements
elements = (0..<titles.count).map { (idx) -> MenuSwitchElementStackView in
if icons?.count ?? 0 > 0 {
elementStackView = MenuSwitchElementStackView(frame: .zero, text: titles[idx], icon: icons?[idx], onState: onStates[idx], switchID: idx)
} else {
elementStackView = MenuSwitchElementStackView(frame: .zero, text: titles[idx], icon: nil, onState: onStates[idx], switchID: idx)
}
elementStackView?.isUserInteractionEnabled = true
elementStackView?.tag = idx
return elementStackView!
}
var singleLines = [UIView]()
let lineThickness: CGFloat = separatorLineThickness
// compose the stackView
if let elements = elements {
for (idx, view) in elements.enumerated() {
// add element to stackView
addArrangedSubview(view)
if idx < titles.count - 1 {
// add single Line to stackView
let singleLineView = UIView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: lineThickness))
singleLineView.backgroundColor = ImageConverter.UIColorFromRGB(0x606060, alpha: 1.0)
singleLines.append(singleLineView)
addArrangedSubview(singleLineView)
}
}
}
// set constraints
translatesAutoresizingMaskIntoConstraints = false
if let elements = elements {
for (idx, element) in elements.enumerated() {
element.translatesAutoresizingMaskIntoConstraints = false
element.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
element.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
element.heightAnchor.constraint(equalToConstant: elementHeight).isActive = true
if idx < titles.count - 1 {
singleLines[idx].translatesAutoresizingMaskIntoConstraints = false
singleLines[idx].topAnchor.constraint(equalTo: element.bottomAnchor).isActive = true
singleLines[idx].heightAnchor.constraint(equalToConstant: lineThickness).isActive = true
if icons?.count ?? 0 > 0 {
if let _ = icons?[idx] {
singleLines[idx].leadingAnchor.constraint(equalTo: leadingAnchor, constant: 39.0).isActive = true
}
} else {
singleLines[idx].leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
}
singleLines[idx].trailingAnchor.constraint(equalTo: trailingAnchor, constant: 9.0).isActive = true
}
}
}
}
}
class ListSectionBackgroundView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
}
convenience init(frame: CGRect, height: CGFloat = 60.0, backgroundColor: UIColor) {
self.init(frame: frame)
composeView(height: height, backgroundColor: backgroundColor)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func composeView(height: CGFloat, backgroundColor: UIColor) {
// View properties
self.backgroundColor = backgroundColor
self.layer.cornerRadius = 10.0
self.layer.masksToBounds = true
self.clipsToBounds = true
// set Constraints
setConstraints(height)
}
fileprivate func setConstraints(_ height: CGFloat) {
self.translatesAutoresizingMaskIntoConstraints = false
self.heightAnchor.constraint(equalToConstant: height).isActive = true
}
}
I finally found a solution.
It was the UIView-extension "anchor" in my code that no longer works under iOS14 for some reason.
Here I describe the problem more precisely...
Therefore, if I use the extension and do the following - then any UIControls in my ViewHierarchy do not work (i.e. no target-actions, switch-didChanges, etc will no longer kick-in)
SettingsStackView.anchor(top: safeAreaLayoutGuide.topAnchor, leading: contentView.leadingAnchor, bottom: contentView.bottomAnchor, trailing: contentView.trailingAnchor)
However, if I do it without the UIView-extension, then everything works fine:
SettingsStackView.translatesAutoresizingMaskIntoConstraints = false
SettingsStackView.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor).isActive = true
SettingsStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
SettingsStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
SettingsStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
Maybe somebody can tell me why this new behaviour came up with iOS14 ???
Problem
How can I modify the scroll target of a scrollView? I am looking for kind of a replacement for the "classic" scrollView delegate method
override func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
...where we can modfify the targeted scrollView.contentOffset via targetContentOffset.pointee for instance to create a custom paging behaviour.
Or in other words: I do want to create a paging effect in a (horizontal) scrollView.
What I have tried ie. is something like this:
ScrollView(.horizontal, showsIndicators: true, content: {
HStack(alignment: VerticalAlignment.top, spacing: 0, content: {
card(title: "1")
card(title: "2")
card(title: "3")
card(title: "4")
})
})
// 3.
.content.offset(x: self.dragState.isDragging == true ? self.originalOffset : self.modifiedOffset, y: 0)
// 4.
.animation(self.dragState.isDragging == true ? nil : Animation.spring())
// 5.
.gesture(horizontalDragGest)
Attempt
This is what I tried (besides a custom scrollView approach):
A scrollView has a content area larger then screen space to enable scrolling at all.
I created a DragGesture() to detect if there is a drag going on. In the .onChanged and .onEnded closures I modified my #State values to create a desired scrollTarget.
Conditionally fed in both the original unchanged and the new modified values into the .content.offset(x: y:) modifier - depending on the dragState as a replacement for missing scrollDelegate methods.
Added animation acting conditionally only when drag has ended.
Attached the gesture to the scrollView.
Long story short. It doesn't work.
I hope I got across what my problem is.
Any solutions out there? Looking forward to any input. Thanks!
I have managed to achieve a paging behaviour with a #Binding index. The solution might look dirty, I'll explain my workarounds.
The first thing I got wrong, was to get alignment to .leading instead of the default .center, otherwise the offset works unusual. Then I combined the binding and a local offset state. This kinda goes against the "Single source of truth" principle, but otherwise I had no idea how to handle external index changes and modify my offset.
So, my code is the following
struct SwiftUIPagerView<Content: View & Identifiable>: View {
#Binding var index: Int
#State private var offset: CGFloat = 0
#State private var isGestureActive: Bool = false
// 1
var pages: [Content]
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.pages) { page in
page
.frame(width: geometry.size.width, height: nil)
}
}
}
// 2
.content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
// 3
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.gesture(DragGesture().onChanged({ value in
// 4
self.isGestureActive = true
// 5
self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
}).onEnded({ value in
if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
self.index += 1
}
if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
self.index -= 1
}
// 6
withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
// 7
DispatchQueue.main.async { self.isGestureActive = false }
}))
}
}
}
you may just wrap your content, I used it for "Tutorial Views".
this a trick to switch between external and internal state changes
.leading is mandatory if you don't want to translate all offsets to center.
set the state to local state change
calculate the full offset from the gesture delta (*-1) plus the previous index state
at the end set the final index based on the gesture predicted end, while rounding the offset up or down
reset the state to handle external changes to index
I have tested it in the following context
struct WrapperView: View {
#State var index: Int = 0
var body: some View {
VStack {
SwiftUIPagerView(index: $index, pages: (0..<4).map { index in TODOView(extraInfo: "\(index + 1)") })
Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
}
.pickerStyle(SegmentedPickerStyle())
.padding()
}
}
}
where TODOView is my custom view that indicates a view to implement.
I hope I get the question right, if not please specify which part should I focus on. Also I welcome any suggestions to remove the isGestureActive state.
#gujci your solution is perfect, for more general usage, make it accept Models and view builder as in (note the I pass the geometry size in the builder) :
struct SwiftUIPagerView<TModel: Identifiable ,TView: View >: View {
#Binding var index: Int
#State private var offset: CGFloat = 0
#State private var isGestureActive: Bool = false
// 1
var pages: [TModel]
var builder : (CGSize, TModel) -> TView
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.pages) { page in
self.builder(geometry.size, page)
}
}
}
// 2
.content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
// 3
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.gesture(DragGesture().onChanged({ value in
// 4
self.isGestureActive = true
// 5
self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
}).onEnded({ value in
if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
self.index += 1
}
if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
self.index -= 1
}
// 6
withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
// 7
DispatchQueue.main.async { self.isGestureActive = false }
}))
}
}
}
and can be used as :
struct WrapperView: View {
#State var index: Int = 0
#State var items : [(color:Color,name:String)] = [
(.red,"Red"),
(.green,"Green"),
(.yellow,"Yellow"),
(.blue,"Blue")
]
var body: some View {
VStack(spacing: 0) {
SwiftUIPagerView(index: $index, pages: self.items.identify { $0.name }) { size, item in
TODOView(extraInfo: item.model.name)
.frame(width: size.width, height: size.height)
.background(item.model.color)
}
Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
}
.pickerStyle(SegmentedPickerStyle())
}.edgesIgnoringSafeArea(.all)
}
}
with the help of some utilities :
struct MakeIdentifiable<TModel,TID:Hashable> : Identifiable {
var id : TID {
return idetifier(model)
}
let model : TModel
let idetifier : (TModel) -> TID
}
extension Array {
func identify<TID: Hashable>(by: #escaping (Element)->TID) -> [MakeIdentifiable<Element, TID>]
{
return self.map { MakeIdentifiable.init(model: $0, idetifier: by) }
}
}
#gujci, thank you for interesting example. I've played with it and removed the isGestureActive state. Full example may be found in my gist.
struct SwiftUIPagerView<Content: View & Identifiable>: View {
#State private var index: Int = 0
#State private var offset: CGFloat = 0
var pages: [Content]
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.pages) { page in
page
.frame(width: geometry.size.width, height: nil)
}
}
}
.content.offset(x: self.offset)
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.gesture(DragGesture()
.onChanged({ value in
self.offset = value.translation.width - geometry.size.width * CGFloat(self.index)
})
.onEnded({ value in
if abs(value.predictedEndTranslation.width) >= geometry.size.width / 2 {
var nextIndex: Int = (value.predictedEndTranslation.width < 0) ? 1 : -1
nextIndex += self.index
self.index = nextIndex.keepIndexInRange(min: 0, max: self.pages.endIndex - 1)
}
withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
})
)
}
}
}
As far as I know scrolls in swiftUI doesn't support anything potentially useful such as scrollViewDidScroll or scrollViewWillEndDragging yet. I suggest using either classic UIKit views for making very custom behavior and cool SwiftUI views for anything that is easier. I've tried that a lot and it actually works! Have a look at this guide. Hope that helps
Alternative solution would be to integrate UIKit into SwiftUI using UIViewRepresentative which links UIKit components with SwiftUI. For additional leads and resources, see how Apple suggests you interface with UIKit: Interfacing with UIKit. They have a good example that shows to page between images and track selection index.
Edit: Until they (Apple) implement some sort of content offset that effects the scroll instead of the entire view, this is their suggested solution since they knew the initial release of SwiftUI wouldn't encompass all functionality of UIKit.
Details
Xcode 14
Swift 5.6.1
Requirements
I do not want to use integration with UIKit (clean SwiftUI ONLY)
I do not want to scroll to the any "ID", I want to scroll to the point
Solution
import SwiftUI
#available(iOS 14.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
struct ExtendedScrollView<Content>: View where Content: View {
private let contentProvider: _AligningContentProvider<Content>
// Main Idea from: https://github.com/edudnyk/SolidScroll/blob/main/Sources/SolidScroll/ScrollView.swift
private var config: _ScrollViewConfig
init(config: _ScrollViewConfig = _ScrollViewConfig(),
#ViewBuilder content: () -> Content) {
contentProvider = _AligningContentProvider(content: content(), horizontal: .center, vertical: .center)
self.config = config
}
init(_ axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
#ViewBuilder content: () -> Content) {
var config = _ScrollViewConfig()
config.showsHorizontalIndicator = axes.contains(.horizontal) && showsIndicators
config.showsVerticalIndicator = axes.contains(.vertical) && showsIndicators
self.init(config: config, content: content)
}
init(config: () -> _ScrollViewConfig,
#ViewBuilder content: () -> Content) {
self.init(config: config(), content: content)
}
var body: some View {
_ScrollView(contentProvider: contentProvider, config: config)
}
}
extension _ContainedScrollViewKey: PreferenceKey {}
// MARK: Track ScrollView Scrolling
struct TrackableExtendedScrollView: ViewModifier {
let onChange: (_ScrollViewProxy?) -> Void
func body(content: Content) -> some View {
content
.onPreferenceChange(_ContainedScrollViewKey.self, perform: onChange)
}
}
extension View {
func onScrollChange(perform: #escaping (_ScrollViewProxy?) -> Void) -> some View {
modifier(TrackableExtendedScrollView(onChange: perform))
}
}
Usage Sample
private var gridItemLayout = (0..<40).map { _ in
GridItem(.fixed(50), spacing: 0, alignment: .leading)
}
// ....
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout) {
ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
Text("\(index)")
.frame(width: 50)
.frame(maxHeight: .infinity)
}
}
}
.onScrollChange { proxy in
// let offset = proxy?.contentOffset.y
}
Full Sample
Implementation details
First column and first row are always on the screen
There are 3 "CollectionView":
first row "CollectionView"
first column "CollectionView"
main content "CollectionView"
All "CollectionView" are synced (if you scroll one "CollectionView", another will also be scrolled)
Do not forget to paste The Solution code here
import SwiftUI
import Combine
struct ContentView: View {
private let columWidth: CGFloat = 50
private var gridItemLayout0 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]
private var gridItemLayout1 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]
private var gridItemLayout = (0..<40).map { _ in
GridItem(.fixed(50), spacing: 0, alignment: .leading)
}
#State var text: String = "scrolling not detected"
#State private var scrollViewProxy1: _ScrollViewProxy?
#State private var tableContentScrollViewProxy: _ScrollViewProxy?
#State private var tableHeaderScrollViewProxy: _ScrollViewProxy?
private let numberOfColumns = 50
private let numberOfRows = 40
let headerColor = Color(0xEE9B00)
let firstColumnColor = Color(0x0A9396)
let headerTextColor = Color(.white)
let horizontalSpacing: CGFloat = 6
let verticalSpacing: CGFloat = 0
let firstColumnWidth: CGFloat = 100
let columnWidth: CGFloat = 60
var body: some View {
VStack(spacing: 0) {
Text("First column and row are sticked to the content")
.foregroundColor(.gray)
Text(text)
HStack {
Rectangle()
.frame(width: firstColumnWidth-2)
.foregroundColor(.clear)
buildFirstCollectionViewRow()
}
.frame(height: 50)
HStack(alignment: .firstTextBaseline, spacing: horizontalSpacing) {
buildFirstCollectionViewColumn()
buildCollectionViewContent()
}
}
}
#ViewBuilder
private func buildFirstCollectionViewRow() -> some View {
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout1, spacing: horizontalSpacing) {
ForEach((0..<numberOfColumns), id: \.self) {
let color = $0%2 == 0 ? Color(0x005F73) : Color(0xCA6702)
Text("Value\($0)")
.frame(width: columnWidth)
.frame(maxHeight: .infinity)
.foregroundColor(headerTextColor)
.background(color)
.font(.system(size: 16, weight: .semibold))
}
}
}
.onScrollChange { proxy in
if tableHeaderScrollViewProxy != proxy { tableHeaderScrollViewProxy = proxy }
guard proxy?.isScrolling ?? false else { return }
if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
let offset = proxy?.contentOffset.x {
tableContentScrollViewProxy?.contentOffset.x = offset
}
text = "scrolling: header"
}
}
}
// MARK: Collection View Elements
extension ContentView {
#ViewBuilder
private func buildFirstCollectionViewColumn() -> some View {
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
ForEach((0..<numberOfRows), id: \.self) {
Text("multi line text \($0)")
.foregroundColor(.white)
.lineLimit(2)
.frame(width: firstColumnWidth)
.font(.system(size: 16, weight: .semibold))
.frame(maxHeight: .infinity)
.background(firstColumnColor)
.border(.white)
}
}
}
.frame(width: firstColumnWidth)
.onScrollChange { proxy in
if scrollViewProxy1 != proxy { scrollViewProxy1 = proxy }
guard proxy?.isScrolling ?? false else { return }
if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
let offset = proxy?.contentOffset.y {
tableContentScrollViewProxy?.contentOffset.y = offset
}
text = "scrolling: 1st column"
}
}
#ViewBuilder
private func buildCollectionViewContent() -> some View {
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
Text("\(index)")
.frame(width: columnWidth)
.frame(maxHeight: .infinity)
.background(color)
.border(.white)
}
}
}
.onScrollChange { proxy in
if tableContentScrollViewProxy != proxy { tableContentScrollViewProxy = proxy }
guard proxy?.isScrolling ?? false else { return }
if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
let offset = proxy?.contentOffset.y {
self.scrollViewProxy1?.contentOffset.y = offset
}
if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
let offset = proxy?.contentOffset.x {
self.tableHeaderScrollViewProxy?.contentOffset.x = offset
}
text = "scrolling: content"
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
extension Color {
init(_ hex: UInt, alpha: Double = 1) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: alpha
)
}
}
Full Sample Demo
I have a question regarding creating a Apple Map Overlay. I am trying to set on odd shape overlay from a JSON file. I have researched this on Stack Overflow, and have tried many of the solutions, but none seem to work. My code is below:
import UIKit
import MapKit
import CoreLocation
class ViewController: UIViewController, CLLocationManagerDelegate, MKMapViewDelegate, UIGestureRecognizerDelegate {
#IBOutlet weak var mapView: MKMapView!
var coordinate: CLLocationCoordinate2D?
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
mapView.showsUserLocation = true
mapView.delegate = self
mapView.mapType = .standard
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.TapGesture))
mapView.addGestureRecognizer(gestureRecognizer)
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if overlay is MKPolygon {
let polygonView = MKPolygonRenderer(overlay: overlay)
polygonView.strokeColor = UIColor.black
polygonView.lineWidth = 0.5
polygonView.fillColor = UIColor.blue
return polygonView
}
return MKOverlayRenderer()
}
#objc func TapGesture(gesRect: UITapGestureRecognizer) {
let location = gesRect.location(in: mapView)
coordinate = mapView.convert(location,toCoordinateFrom: mapView)
let locCoord = mapView.convert(location, toCoordinateFrom: mapView)
print("Tapped at lat: \(locCoord.latitude) long: \(locCoord.longitude)")
print("Tapped at: \(location)")
self.retreiveShape() { (full_shape) in
if let shape = full_shape {
let polygon = MKPolygon.init(coordinates: shape, count: shape.count)
self.mapView.addOverlay(polygon)
} else {
print("ARRAY EMPTY")
}
}
}
func retreiveShape(completion: #escaping ([CLLocationCoordinate2D]?) -> ()) {
let path = Bundle.main.path(forResource: "shape", ofType: "json")
var coord_array = [CLLocationCoordinate2D]()
do {
let data = try Data.init(contentsOf: URL.init(fileURLWithPath: path!))
let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments)
if let dictionary = json as? [String: Any] {
if let shape = dictionary["shape"] as? Array<Any> {
for regions in shape {
guard let region = regions as? Array<Array<Array<Double>>> else {
print("NOT HAPPENING")
return
}
for sections in region {
for coord in sections {
print("LATITUDE: \(coord[0])", "LONGITUDE: \(coord[1])")
let coordinatesToAppend = CLLocationCoordinate2D(latitude: coord[0], longitude: coord[1])
coord_array.append(coordinatesToAppend)
}
}
}
completion(coord_array)
}
}
} catch let error {
print(error)
}
}
The shape.json file is below:
{
"shape":[
[
[
[-81.621199, 30.282314],
[-81.613987, 30.281941],
[-81.611277, 30.284743],
[-81.602735, 30.284026],
[-81.601978, 30.292561],
[-81.596275, 30.290861],
[-81.592406, 30.290182],
[-81.571146, 30.28763],
[-81.55922, 30.286602],
[-81.559148, 30.291132],
[-81.558633, 30.294747],
[-81.55881, 30.312887],
[-81.558601, 30.312888],
[-81.558622, 30.316235],
[-81.558313, 30.316828],
[-81.552252, 30.320252],
[-81.548471, 30.321618],
[-81.527882, 30.323989],
[-81.529486, 30.328076],
[-81.537635, 30.336704],
[-81.537706, 30.337221],
[-81.538717, 30.338277],
[-81.539343, 30.338462],
[-81.542809, 30.341686],
[-81.547286, 30.345211],
[-81.552498, 30.348839],
[-81.552559, 30.352445],
[-81.577566, 30.352039],
[-81.578098, 30.353324],
[-81.578161, 30.35642],
[-81.577294, 30.3596],
[-81.576996, 30.366609],
[-81.58011, 30.366553],
[-81.580875, 30.37062],
[-81.580844, 30.373862],
[-81.581462, 30.374486],
[-81.578114, 30.374236],
[-81.572908, 30.374611],
[-81.562232, 30.372303],
[-81.551965, 30.366559],
[-81.548676, 30.365568],
[-81.540187, 30.378172],
[-81.538175, 30.380467],
[-81.538213, 30.387239],
[-81.536613, 30.388739],
[-81.512612, 30.392739],
[-81.505211, 30.390739],
[-81.490911, 30.392139],
[-81.49085, 30.389014],
[-81.489978, 30.389207],
[-81.488818, 30.38775],
[-81.489203, 30.389266],
[-81.487056, 30.390019],
[-81.481446, 30.391262],
[-81.479505, 30.39117],
[-81.477708, 30.390635],
[-81.476792, 30.390609],
[-81.476244, 30.391002],
[-81.473212, 30.389422],
[-81.472125, 30.388436],
[-81.472225, 30.388071],
[-81.474072, 30.386758],
[-81.475085, 30.384287],
[-81.474394, 30.381898],
[-81.473246, 30.38059],
[-81.473337, 30.380112],
[-81.47295, 30.379864],
[-81.472643, 30.380053],
[-81.471914, 30.379532],
[-81.471629, 30.378346],
[-81.470845, 30.377256],
[-81.468671, 30.376016],
[-81.466871, 30.374481],
[-81.465402, 30.374424],
[-81.464374, 30.373764],
[-81.465116, 30.373015],
[-81.467728, 30.372493],
[-81.469102, 30.371435],
[-81.470279, 30.369931],
[-81.472008, 30.370608],
[-81.473695, 30.370041],
[-81.471862, 30.370238],
[-81.470952, 30.369737],
[-81.471715, 30.369462],
[-81.470506, 30.369378],
[-81.469456, 30.368207],
[-81.468051, 30.367707],
[-81.46754, 30.366828],
[-81.466905, 30.366464],
[-81.467432, 30.366219],
[-81.466928, 30.365735],
[-81.465222, 30.365136],
[-81.464909, 30.364103],
[-81.46316, 30.362764],
[-81.463369, 30.36188],
[-81.462197, 30.361235],
[-81.461151, 30.36123],
[-81.46117, 30.360531],
[-81.461878, 30.360305],
[-81.461619, 30.359642],
[-81.461873, 30.358669],
[-81.461645, 30.358376],
[-81.460504, 30.358329],
[-81.46288, 30.357969],
[-81.462786, 30.357137],
[-81.461247, 30.355282],
[-81.460556, 30.352518],
[-81.46184, 30.340222],
[-81.462497, 30.339325],
[-81.465064, 30.337897],
[-81.471588, 30.328301],
[-81.472988, 30.318258],
[-81.469123, 30.319481],
[-81.450496, 30.320896],
[-81.443818, 30.302908],
[-81.442451, 30.301512],
[-81.438991, 30.299798],
[-81.437921, 30.298031],
[-81.437696, 30.284657],
[-81.438134, 30.283427],
[-81.439935, 30.281191],
[-81.440578, 30.279729],
[-81.440309, 30.276152],
[-81.441217, 30.271746],
[-81.440891, 30.270368],
[-81.440247, 30.269313],
[-81.438555, 30.267721],
[-81.43765, 30.266188],
[-81.43705, 30.257116],
[-81.441869, 30.256519],
[-81.45385, 30.252008],
[-81.466184, 30.251073],
[-81.472173, 30.251296],
[-81.491372, 30.251034],
[-81.507105, 30.253603],
[-81.510744, 30.253761],
[-81.530261, 30.250144],
[-81.56957, 30.249854],
[-81.584658, 30.251369],
[-81.586895, 30.251326],
[-81.589607, 30.250593],
[-81.593308, 30.248471],
[-81.605497, 30.260294],
[-81.621493, 30.282334],
[-81.621199, 30.282314]
]
]
]
}
It should create an odd shape overlay in the Southside of Jacksonville,FL, but it isn't. When the completion block is called the Coordinates are added to the array, but the map overlay isn't showing. Any thoughts?
Well this is somewhat embarrassing. I did as was suggested in the comments, and tried having the shape with nine vertices. It still didn't work. I then changed the coord from:
print("LATITUDE: \(coord[0])", "LONGITUDE: \(coord[1])")
let coordinatesToAppend = CLLocationCoordinate2D(latitude: coord[0], longitude: coord[1])
to:
print("LATITUDE: \(coord[1])", "LONGITUDE: \(coord[0])")
let coordinatesToAppend = CLLocationCoordinate2D(latitude: coord[1], longitude: coord[0])
It works perfectly. Turns out I had the Latitude and Longitude wrong.