SwiftUI List not updating inside pull to refresh view - ios

I have a List inside a Custom Pull to refresh view. When the array inside SeeAllViewModel is updated, this list in the view is not being updated. Not only that but the counter is not being updated also. When I put the list outside this CustomScrollView it updates just fine. So I'm guessing there is something wrong with my CustomScrollView. Any idea why this is happening? Also I will provide the code for my ViewModel, just in case.
struct SeeAllView: View {
#ObservedObject var seeAllViewModel: SeeAllViewModel
var body: some View {
GeometryReader { geometry in
VStack {
Text("\(self.seeAllViewModel.category.items.count)") // updated on refresh
CustomScrollView(width: geometry.size.width, height: geometry.size.height, viewModel: self.seeAllViewModel) {
VStack {
Text("\(self.seeAllViewModel.category.items.count)") // not being updated
List {
ForEach(self.seeAllViewModel.category.items) { (item: Item) in
ItemRowView(itemViewModel: ItemViewModel(item: item))
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle(Text(self.seeAllViewModel.category.title.firstCapitalized))
}
}
Button(action: {
self.seeAllViewModel.refresh()
}) { Text("refresh")
}
}
}
}
}
CustomScrollView
struct CustomScrollView<Content: View, VM: LoadProtocol> : UIViewRepresentable {
var width : CGFloat
var height : CGFloat
let viewModel: VM
let content: () -> Content
func makeCoordinator() -> Coordinator {
Coordinator(self, viewModel: viewModel)
}
func makeUIView(context: Context) -> UIScrollView {
let control = UIScrollView()
control.refreshControl = UIRefreshControl()
control.refreshControl?.addTarget(context.coordinator, action: #selector(Coordinator.handleRefreshControl), for: .valueChanged)
let childView = UIHostingController(rootView: content())
childView.view.frame = CGRect(x: 0, y: 0, width: width, height: height)
control.addSubview(childView.view)
return control
}
func updateUIView(_ uiView: UIScrollView, context: Context) { }
class Coordinator: NSObject {
var control: CustomScrollView<Content, VM>
var viewModel: VM
init(_ control: CustomScrollView, viewModel: VM) {
self.control = control
self.viewModel = viewModel
}
#objc func handleRefreshControl(sender: UIRefreshControl) {
sender.endRefreshing()
viewModel.refresh()
}
}
}
SeeAllViewModel
class SeeAllViewModel: ObservableObject, LoadProtocol {
#Published var category: Category
init(category: Category) {
self.category = category
}
func refresh() {
//everytime you need more data fetched and on database updates to your snapshot this will be triggered
let query = self.category.query.start(afterDocument: self.category.lastDocumentSnapshot!).limit(to: 1)
query.addSnapshotListener { (snapshot, error) in
guard let snapshot = snapshot else {
print("Error retrieving cities: \(error.debugDescription)")
return
}
guard let lastSnapshot = snapshot.documents.last else {
// The collection is empty.
return
}
self.category.lastDocumentSnapshot = lastSnapshot
// Construct a new query starting after this document,
// Use the query for pagination.
self.category.items += snapshot.documents.map { document -> Item in
return Item(document: document)
}
}
}
}

It appears that dynamic property update cannot pass boundary of different hosting controller, so the solution is pass it (in this case observable object) inside explicitly.
Tested with Xcode 11.4 / iOS 13.4 on replicated code
So, custom view is constructed as
CustomScrollView(width: geometry.size.width, height: geometry.size.height, viewModel: self.seeAllViewModel) {
// Separate internals to subview and pass view modal there
RefreshInternalView(seeAllViewModel: self.seeAllViewModel)
}
and here is separated view, nothing special - just extracted everything from above
struct RefreshInternalView: View {
#ObservedObject var seeAllViewModel: SeeAllViewModel
var body: some View {
VStack {
Text("\(self.seeAllViewModel.category.items.count)") // not being updated
List {
ForEach(self.seeAllViewModel.category.items) { (item: Item) in
ItemRowView(itemViewModel: ItemViewModel(item: item))
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle(Text(self.seeAllViewModel.category.title.firstCapitalized))
}
}
}

Related

UICloudSharingController Does not Display/Work with CloudKit App

I have been struggling to get an app to work with CloudKit and record sharing. I have
created several apps that sync Core Data records among devices for one user. I have not
been able to get the UICloudSharingController to work for sharing records. I can display
the Sharing View Controller, but tapping on Mail or Message displays a keyboard but no
address field and no way to dismiss the view. I have been so frustrated by it that I
decided to try the Apple "Sharing" sample app to start from the basics. However, the
sample app does not work for me either.
Here's the link to the sample app:
https://github.com/apple/cloudkit-sample-sharing/tree/swift-concurrency
The code below is pretty much straight from the sample app.
This is the ContentView file:
import SwiftUI
import CloudKit
struct ContentView: View {
#EnvironmentObject private var vm: ViewModel
#State private var isAddingContact = false
#State private var isSharing = false
#State private var isProcessingShare = false
#State private var showShareView = false
#State private var showIntermediateView = false
#State private var activeShare: CKShare?
#State private var activeContainer: CKContainer?
var body: some View {
NavigationView {
contentView
.navigationTitle("Contacts")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { Task.init { try await vm.refresh() } } label: { Image(systemName: "arrow.clockwise") }
}
ToolbarItem(placement: .navigationBarLeading) {
progressView
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { isAddingContact = true }) { Image(systemName: "plus") }
}
}
}
.onAppear {
Task.init {
try await vm.initialize()
try await vm.refresh()
}
}
.sheet(isPresented: $isAddingContact, content: {
AddContactView(onAdd: addContact, onCancel: { isAddingContact = false })
})
}
/// This progress view will display when either the ViewModel is loading, or a share is processing.
var progressView: some View {
let showProgress: Bool = {
if case .loading = vm.state {
return true
} else if isProcessingShare {
return true
}
return false
}()
return Group {
if showProgress {
ProgressView()
}
}
}
/// Dynamic view built from ViewModel state.
private var contentView: some View {
Group {
switch vm.state {
case let .loaded(privateContacts, sharedContacts):
List {
Section(header: Text("Private")) {
ForEach(privateContacts) { contactRowView(for: $0) }
}
Section(header: Text("Shared")) {
ForEach(sharedContacts) { contactRowView(for: $0, shareable: false) }
}
}.listStyle(GroupedListStyle())
case .error(let error):
VStack {
Text("An error occurred: \(error.localizedDescription)").padding()
Spacer()
}
case .loading:
VStack { EmptyView() }
}
}
}
/// Builds a `CloudSharingView` with state after processing a share.
private func shareView() -> CloudSharingView? {
guard let share = activeShare, let container = activeContainer else {
return nil
}
return CloudSharingView(container: container, share: share)
}
/// Builds a Contact row view for display contact information in a List.
private func contactRowView(for contact: Contact, shareable: Bool = true) -> some View {
HStack {
VStack(alignment: .leading) {
Text(contact.name)
Text(contact.phoneNumber)
.textContentType(.telephoneNumber)
.font(.footnote)
}
if shareable {
Spacer()
Button(action: { Task.init { try? await shareContact(contact) }
}, label: { Image(systemName: "square.and.arrow.up") }).buttonStyle(BorderlessButtonStyle())
.sheet(isPresented: $isSharing, content: { shareView() })
}//if sharable
}//h
}//contact row view
// MARK: - Actions
private func addContact(name: String, phoneNumber: String) async throws {
try await vm.addContact(name: name, phoneNumber: phoneNumber)
try await vm.refresh()
isAddingContact = false
}
private func shareContact(_ contact: Contact) async throws {
isProcessingShare = true
do {
let (share, container) = try await vm.createShare(contact: contact)
isProcessingShare = false
activeShare = share
activeContainer = container
isSharing = true
} catch {
debugPrint("Error sharing contact record: \(error)")
}
}
}
And the UIViewControllerRepresentable file for the sharing view:
import Foundation
import SwiftUI
import UIKit
import CloudKit
/// This struct wraps a `UIImagePickerController` for use in SwiftUI.
struct CloudSharingView: UIViewControllerRepresentable {
#Environment(\.presentationMode) var presentationMode
let container: CKContainer
let share: CKShare
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
func makeUIViewController(context: Context) -> some UIViewController {
let sharingController = UICloudSharingController(share: share, container: container)
sharingController.availablePermissions = [.allowReadWrite, .allowPrivate]
sharingController.delegate = context.coordinator
return sharingController
}
func makeCoordinator() -> CloudSharingView.Coordinator {
Coordinator()
}
final class Coordinator: NSObject, UICloudSharingControllerDelegate {
func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) {
debugPrint("Error saving share: \(error)")
}
func itemTitle(for csc: UICloudSharingController) -> String? {
"Sharing Example"
}
}
}
This is the presented screen when tapping to share a record. This all looks as expected:
This is the screen after tapping Messages (same result for Mail). I don't see any way to influence the second screen presentation - it seems that the view controller representable is not working with this version of Xcode:
Any guidance would be appreciated. Xcode 13.1, iOS 15 and I am on macOS Monterrey.
I had the same issue and fixed it by changing makeUIViewController() in CloudSharingView.swift:
func makeUIViewController(context: Context) -> some UIViewController {
share[CKShare.SystemFieldKey.title] = "Boom"
let sharingController = UICloudSharingController(share: share, container: container)
sharingController.availablePermissions = [.allowReadWrite, .allowPrivate]
sharingController.delegate = context.coordinator
-->>> sharingController.modalPresentationStyle = .none
return sharingController
}
It seems like some value work, some don't. Not sure why.

SwiftUI ChildView Not recalculated on state change

So this is most likely a newbie SwiftUI question, I have a parent view which takes an #ObservedObject (e.g. viewModel) the view model seems to be correctly publishes changes in the model to the CollectionView, and view is correctly recalculating the body. However, the child view's (EmojiCell) body doesn't seem to be recalculated? Below is the related code, appreciate any insight.
I can get this to work, if I change the Card to be ObservableClass and to have its isFaceUp as #Published but this obviously is not the right solution!
struct CollectionView: View {
#ObservedObject var viewModel: EmojiMemoryGameViewModel
init(viewModel: EmojiMemoryGameViewModel) {
self.viewModel = viewModel
}
var body: some View {
// This is called on tap as expected so the viewModel is indeed
// correctly notifying the view of it's change, the body is
// recalculated, BUT EmojiCell's body doesn't get called!
print("CollectionView recalc")
return HStack {
ForEach(viewModel.cards) { card in
// This doesn't
EmojiCell(card: card).onTapGesture {
viewModel.choose(card: card)
}
// This works!
// return ZStack {
// if card.isFaceUp {
// RoundedRectangle(cornerRadius: 10)
// .fill(Color.white)
// RoundedRectangle(cornerRadius: 10)
// .stroke(lineWidth: 3)
// Text(card.content)
// }
// else {
// RoundedRectangle(cornerRadius: 10).fill()
// }
// }
// .onTapGesture {
// self.viewModel.choose(card: card)
// }
}
}
.padding()
.foregroundColor(.orange)
.font(.largeTitle)
}
}
typealias EmojiCard = MemoryGame<String>.Card
struct EmojiCell: View {
let card: EmojiCard
var body: some View {
print("Cell recalc")
return ZStack {
if card.isFaceUp {
RoundedRectangle(cornerRadius: 10)
.fill(Color.white)
RoundedRectangle(cornerRadius: 10)
.stroke(lineWidth: 3)
Text(card.content)
}
else {
RoundedRectangle(cornerRadius: 10).fill()
}
}
}
}
// Other relevant code
struct Card: Identifiable, Hashable {
var id: Int
var isFaceUp = true
var isMatched = false
var content: CardContent
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
extension MemoryGame.Card: Equatable {
static func == (lhs: MemoryGame<CardContent>.Card, rhs: MemoryGame<CardContent>.Card) -> Bool {
return lhs.id == rhs.id
}
}
class EmojiMemoryGameViewModel: ObservableObject {
#Published private var model: MemoryGame<String> = EmojiMemoryGameViewModel.createMemoryGame()
static func createMemoryGame() -> MemoryGame<String> {
let listOfCards = ["👻", "👽", "👾"]
return MemoryGame(numberOfPairsOfCards: listOfCards.count) { index in
return listOfCards[index]
}
}
var cards: [MemoryGame<String>.Card] {
model.cards
}
func choose(card: MemoryGame<String>.Card) {
model.choose(card)
}
}
This was a silly mistake on my part. The problem was actually immature implementation of Equatable which is actually no longer needed as of Swift 4.1+ (same for Hashable). I originally added it to ensure I could compare Cards (needed for index(of:) method), however, since I was only checking comparing id's, it was messing with SwiftUI's internal comparison algorithm for redrawing. SwiftUI too was using my Equatable implementation and thinking oh the Card actually has not changed!

SwiftUI | Using onDrag and onDrop to reorder Items within one single LazyGrid?

I was wondering if it is possible to use the View.onDrag and View.onDrop to add drag and drop reordering within one LazyGrid manually?
Though I was able to make every Item draggable using onDrag, I have no idea how to implement the dropping part.
Here is the code I was experimenting with:
import SwiftUI
//MARK: - Data
struct Data: Identifiable {
let id: Int
}
//MARK: - Model
class Model: ObservableObject {
#Published var data: [Data]
let columns = [
GridItem(.fixed(160)),
GridItem(.fixed(160))
]
init() {
data = Array<Data>(repeating: Data(id: 0), count: 100)
for i in 0..<data.count {
data[i] = Data(id: i)
}
}
}
//MARK: - Grid
struct ContentView: View {
#StateObject private var model = Model()
var body: some View {
ScrollView {
LazyVGrid(columns: model.columns, spacing: 32) {
ForEach(model.data) { d in
ItemView(d: d)
.id(d.id)
.frame(width: 160, height: 240)
.background(Color.green)
.onDrag { return NSItemProvider(object: String(d.id) as NSString) }
}
}
}
}
}
//MARK: - GridItem
struct ItemView: View {
var d: Data
var body: some View {
VStack {
Text(String(d.id))
.font(.headline)
.foregroundColor(.white)
}
}
}
Thank you!
SwiftUI 2.0
Here is completed simple demo of possible approach (did not tune it much, `cause code growing fast as for demo).
Important points are: a) reordering does not suppose waiting for drop, so should be tracked on the fly; b) to avoid dances with coordinates it is more simple to handle drop by grid item views; c) find what to where move and do this in data model, so SwiftUI animate views by itself.
Tested with Xcode 12b3 / iOS 14
import SwiftUI
import UniformTypeIdentifiers
struct GridData: Identifiable, Equatable {
let id: Int
}
//MARK: - Model
class Model: ObservableObject {
#Published var data: [GridData]
let columns = [
GridItem(.fixed(160)),
GridItem(.fixed(160))
]
init() {
data = Array(repeating: GridData(id: 0), count: 100)
for i in 0..<data.count {
data[i] = GridData(id: i)
}
}
}
//MARK: - Grid
struct DemoDragRelocateView: View {
#StateObject private var model = Model()
#State private var dragging: GridData?
var body: some View {
ScrollView {
LazyVGrid(columns: model.columns, spacing: 32) {
ForEach(model.data) { d in
GridItemView(d: d)
.overlay(dragging?.id == d.id ? Color.white.opacity(0.8) : Color.clear)
.onDrag {
self.dragging = d
return NSItemProvider(object: String(d.id) as NSString)
}
.onDrop(of: [UTType.text], delegate: DragRelocateDelegate(item: d, listData: $model.data, current: $dragging))
}
}.animation(.default, value: model.data)
}
}
}
struct DragRelocateDelegate: DropDelegate {
let item: GridData
#Binding var listData: [GridData]
#Binding var current: GridData?
func dropEntered(info: DropInfo) {
if item != current {
let from = listData.firstIndex(of: current!)!
let to = listData.firstIndex(of: item)!
if listData[to].id != current!.id {
listData.move(fromOffsets: IndexSet(integer: from),
toOffset: to > from ? to + 1 : to)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
return DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
self.current = nil
return true
}
}
//MARK: - GridItem
struct GridItemView: View {
var d: GridData
var body: some View {
VStack {
Text(String(d.id))
.font(.headline)
.foregroundColor(.white)
}
.frame(width: 160, height: 240)
.background(Color.green)
}
}
Edit
Here is how to fix the never disappearing drag item when dropped outside of any grid item:
struct DropOutsideDelegate: DropDelegate {
#Binding var current: GridData?
func performDrop(info: DropInfo) -> Bool {
current = nil
return true
}
}
struct DemoDragRelocateView: View {
...
var body: some View {
ScrollView {
...
}
.onDrop(of: [UTType.text], delegate: DropOutsideDelegate(current: $dragging))
}
}
Here's my solution (based on Asperi's answer) for those who seek for a generic approach for ForEach where I abstracted the view away:
struct ReorderableForEach<Content: View, Item: Identifiable & Equatable>: View {
let items: [Item]
let content: (Item) -> Content
let moveAction: (IndexSet, Int) -> Void
// A little hack that is needed in order to make view back opaque
// if the drag and drop hasn't ever changed the position
// Without this hack the item remains semi-transparent
#State private var hasChangedLocation: Bool = false
init(
items: [Item],
#ViewBuilder content: #escaping (Item) -> Content,
moveAction: #escaping (IndexSet, Int) -> Void
) {
self.items = items
self.content = content
self.moveAction = moveAction
}
#State private var draggingItem: Item?
var body: some View {
ForEach(items) { item in
content(item)
.overlay(draggingItem == item && hasChangedLocation ? Color.white.opacity(0.8) : Color.clear)
.onDrag {
draggingItem = item
return NSItemProvider(object: "\(item.id)" as NSString)
}
.onDrop(
of: [UTType.text],
delegate: DragRelocateDelegate(
item: item,
listData: items,
current: $draggingItem,
hasChangedLocation: $hasChangedLocation
) { from, to in
withAnimation {
moveAction(from, to)
}
}
)
}
}
}
The DragRelocateDelegate basically stayed the same, although I made it a bit more generic and safer:
struct DragRelocateDelegate<Item: Equatable>: DropDelegate {
let item: Item
var listData: [Item]
#Binding var current: Item?
#Binding var hasChangedLocation: Bool
var moveAction: (IndexSet, Int) -> Void
func dropEntered(info: DropInfo) {
guard item != current, let current = current else { return }
guard let from = listData.firstIndex(of: current), let to = listData.firstIndex(of: item) else { return }
hasChangedLocation = true
if listData[to] != current {
moveAction(IndexSet(integer: from), to > from ? to + 1 : to)
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
hasChangedLocation = false
current = nil
return true
}
}
And finally here is the actual usage:
ReorderableForEach(items: itemsArr) { item in
SomeFancyView(for: item)
} moveAction: { from, to in
itemsArr.move(fromOffsets: from, toOffset: to)
}
There was a few additional issues raised to the excellent solutions above, so here's what I could come up with on Jan 1st with a hangover (i.e. apologies for being less than eloquent):
If you pick a griditem and release it (to cancel), then the view is not reset
I added a bool that checks if the view had been dragged yet, and if it hasn't then it doesn't hide the view in the first place. It's a bit of a hack, because it doesn't really reset, it just postpones hiding the view until it knows that you want to drag it. I.e. if you drag really fast, you can see the view briefly before it's hidden.
If you drop a griditem outside the view, then the view is not reset
This one was partially addressed already, by adding the dropOutside delegate, but SwiftUI doesn't trigger it unless you have a background view (like a color), which I think caused some confusion. I therefore added a background in grey to illustrate how to properly trigger it.
Hope this helps anyone:
import SwiftUI
import UniformTypeIdentifiers
struct GridData: Identifiable, Equatable {
let id: String
}
//MARK: - Model
class Model: ObservableObject {
#Published var data: [GridData]
let columns = [
GridItem(.flexible(minimum: 60, maximum: 60))
]
init() {
data = Array(repeating: GridData(id: "0"), count: 50)
for i in 0..<data.count {
data[i] = GridData(id: String("\(i)"))
}
}
}
//MARK: - Grid
struct DemoDragRelocateView: View {
#StateObject private var model = Model()
#State private var dragging: GridData? // I can't reset this when user drops view ins ame location as drag started
#State private var changedView: Bool = false
var body: some View {
VStack {
ScrollView(.vertical) {
LazyVGrid(columns: model.columns, spacing: 5) {
ForEach(model.data) { d in
GridItemView(d: d)
.opacity(dragging?.id == d.id && changedView ? 0 : 1)
.onDrag {
self.dragging = d
changedView = false
return NSItemProvider(object: String(d.id) as NSString)
}
.onDrop(of: [UTType.text], delegate: DragRelocateDelegate(item: d, listData: $model.data, current: $dragging, changedView: $changedView))
}
}.animation(.default, value: model.data)
}
}
.frame(maxWidth:.infinity, maxHeight: .infinity)
.background(Color.gray.edgesIgnoringSafeArea(.all))
.onDrop(of: [UTType.text], delegate: DropOutsideDelegate(current: $dragging, changedView: $changedView))
}
}
struct DragRelocateDelegate: DropDelegate {
let item: GridData
#Binding var listData: [GridData]
#Binding var current: GridData?
#Binding var changedView: Bool
func dropEntered(info: DropInfo) {
if current == nil { current = item }
changedView = true
if item != current {
let from = listData.firstIndex(of: current!)!
let to = listData.firstIndex(of: item)!
if listData[to].id != current!.id {
listData.move(fromOffsets: IndexSet(integer: from),
toOffset: to > from ? to + 1 : to)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
return DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
changedView = false
self.current = nil
return true
}
}
struct DropOutsideDelegate: DropDelegate {
#Binding var current: GridData?
#Binding var changedView: Bool
func dropEntered(info: DropInfo) {
changedView = true
}
func performDrop(info: DropInfo) -> Bool {
changedView = false
current = nil
return true
}
}
//MARK: - GridItem
struct GridItemView: View {
var d: GridData
var body: some View {
VStack {
Text(String(d.id))
.font(.headline)
.foregroundColor(.white)
}
.frame(width: 60, height: 60)
.background(Circle().fill(Color.green))
}
}
Goal: Reordering Items in HStack
I was trying to figure out how to leverage this solution in SwiftUI for macOS when dragging icons to re-order a horizontal set of items. Thanks to #ramzesenok and #Asperi for the overall solution. I added a CGPoint property along with their solution to achieve the desired behavior. See the animation below.
Define the point
#State private var drugItemLocation: CGPoint?
I used in dropEntered, dropExited, and performDrop DropDelegate functions.
func dropEntered(info: DropInfo) {
if current == nil {
current = item
drugItemLocation = info.location
}
guard item != current,
let current = current,
let from = icons.firstIndex(of: current),
let toIndex = icons.firstIndex(of: item) else { return }
hasChangedLocation = true
drugItemLocation = info.location
if icons[toIndex] != current {
icons.move(fromOffsets: IndexSet(integer: from), toOffset: toIndex > from ? toIndex + 1 : toIndex)
}
}
func dropExited(info: DropInfo) {
drugItemLocation = nil
}
func performDrop(info: DropInfo) -> Bool {
hasChangedLocation = false
drugItemLocation = nil
current = nil
return true
}
For a full demo, I created a gist using Playgrounds
Here is how you implement the on drop part. But remember the ondrop can allow content to be dropped in from outside the app if the data conforms to the UTType. More on UTTypes.
Add the onDrop instance to your lazyVGrid.
LazyVGrid(columns: model.columns, spacing: 32) {
ForEach(model.data) { d in
ItemView(d: d)
.id(d.id)
.frame(width: 160, height: 240)
.background(Color.green)
.onDrag { return NSItemProvider(object: String(d.id) as NSString) }
}
}.onDrop(of: ["public.plain-text"], delegate: CardsDropDelegate(listData: $model.data))
Create a DropDelegate to handling dropped content and the drop location with the given view.
struct CardsDropDelegate: DropDelegate {
#Binding var listData: [MyData]
func performDrop(info: DropInfo) -> Bool {
// check if data conforms to UTType
guard info.hasItemsConforming(to: ["public.plain-text"]) else {
return false
}
let items = info.itemProviders(for: ["public.plain-text"])
for item in items {
_ = item.loadObject(ofClass: String.self) { data, _ in
// idea is to reindex data with dropped view
let index = Int(data!)
DispatchQueue.main.async {
// id of dropped view
print("View Id dropped \(index)")
}
}
}
return true
}
}
Also the only real useful parameter of performDrop is info.location a CGPoint of the drop location, Mapping a CGPoint to the view you want to replace seems unreasonable. I would think the OnMove would be a better option and would make moving your data/Views a breeze. I was unsuccessful to get OnMove working within a LazyVGrid.
As LazyVGrid are still in beta and are bound to change. I would abstain from use on more complex tasks.
I came with a bit different approach that works fine on macOS. Instead of using .onDrag and .onDrop Im using .gesture(DragGesture) with a helper class and modifiers.
Here are helper objects (just copy this to the new file):
// Helper class for dragging objects inside LazyVGrid.
// Grid items must be of the same size
final class DraggingManager<Entry: Identifiable>: ObservableObject {
let coordinateSpaceID = UUID()
private var gridDimensions: CGRect = .zero
private var numberOfColumns = 0
private var numberOfRows = 0
private var framesOfEntries = [Int: CGRect]() // Positions of entries views in coordinate space
func setFrameOfEntry(at entryIndex: Int, frame: CGRect) {
guard draggedEntry == nil else { return }
framesOfEntries[entryIndex] = frame
}
var initialEntries: [Entry] = [] {
didSet {
entries = initialEntries
calculateGridDimensions()
}
}
#Published // Currently displayed (while dragging)
var entries: [Entry]?
var draggedEntry: Entry? { // Detected when dragging starts
didSet { draggedEntryInitialIndex = initialEntries.firstIndex(where: { $0.id == draggedEntry?.id }) }
}
var draggedEntryInitialIndex: Int?
var draggedToIndex: Int? { // Last index where device was dragged to
didSet {
guard let draggedToIndex, let draggedEntryInitialIndex, let draggedEntry else { return }
var newArray = initialEntries
newArray.remove(at: draggedEntryInitialIndex)
newArray.insert(draggedEntry, at: draggedToIndex)
withAnimation {
entries = newArray
}
}
}
func indexForPoint(_ point: CGPoint) -> Int {
let x = max(0, min(Int((point.x - gridDimensions.origin.x) / gridDimensions.size.width), numberOfColumns - 1))
let y = max(0, min(Int((point.y - gridDimensions.origin.y) / gridDimensions.size.height), numberOfRows - 1))
return max(0, min(y * numberOfColumns + x, initialEntries.count - 1))
}
private func calculateGridDimensions() {
let allFrames = framesOfEntries.values
let rows = Dictionary(grouping: allFrames) { frame in
frame.origin.y
}
numberOfRows = rows.count
numberOfColumns = rows.values.map(\.count).max() ?? 0
let minX = allFrames.map(\.minX).min() ?? 0
let maxX = allFrames.map(\.maxX).max() ?? 0
let minY = allFrames.map(\.minY).min() ?? 0
let maxY = allFrames.map(\.maxY).max() ?? 0
let width = (maxX - minX) / CGFloat(numberOfColumns)
let height = (maxY - minY) / CGFloat(numberOfRows)
let origin = CGPoint(x: minX, y: minY)
let size = CGSize(width: width, height: height)
gridDimensions = CGRect(origin: origin, size: size)
}
}
struct Draggable<Entry: Identifiable>: ViewModifier {
#Binding
var originalEntries: [Entry]
let draggingManager: DraggingManager<Entry>
let entry: Entry
#ViewBuilder
func body(content: Content) -> some View {
if let entryIndex = originalEntries.firstIndex(where: { $0.id == entry.id }) {
let isBeingDragged = entryIndex == draggingManager.draggedEntryInitialIndex
let scale: CGFloat = isBeingDragged ? 1.1 : 1.0
content.background(
GeometryReader { geometry -> Color in
draggingManager.setFrameOfEntry(at: entryIndex, frame: geometry.frame(in: .named(draggingManager.coordinateSpaceID)))
return .clear
}
)
.scaleEffect(x: scale, y: scale)
.gesture(
dragGesture(
draggingManager: draggingManager,
entry: entry,
originalEntries: $originalEntries
)
)
}
else {
content
}
}
func dragGesture<Entry: Identifiable>(draggingManager: DraggingManager<Entry>, entry: Entry, originalEntries: Binding<[Entry]>) -> some Gesture {
DragGesture(coordinateSpace: .named(draggingManager.coordinateSpaceID))
.onChanged { value in
// Detect start of dragging
if draggingManager.draggedEntry?.id != entry.id {
withAnimation {
draggingManager.initialEntries = originalEntries.wrappedValue
draggingManager.draggedEntry = entry
}
}
let point = draggingManager.indexForPoint(value.location)
if point != draggingManager.draggedToIndex {
draggingManager.draggedToIndex = point
}
}
.onEnded { value in
withAnimation {
originalEntries.wrappedValue = draggingManager.entries!
draggingManager.entries = nil
draggingManager.draggedEntry = nil
draggingManager.draggedToIndex = nil
}
}
}
}
extension View {
// Allows item in LazyVGrid to be dragged between other items.
func draggable<Entry: Identifiable>(draggingManager: DraggingManager<Entry>, entry: Entry, originalEntries: Binding<[Entry]>) -> some View {
self.modifier(Draggable(originalEntries: originalEntries, draggingManager: draggingManager, entry: entry))
}
}
Now to use it in view you have to do few things:
Create a draggingManager that is a StateObject
Create a var that exposes either real array you are using or temporary array used by draggingManager during dragging.
Apply coordinateSpace from draggingManager to the container (LazyVGrid)
That way draggingManager only modifies its copy of the array during the process, and you can update the original after dragging is done.
struct VirtualMachineSettingsDevicesView: View {
#ObservedObject
var vmEntity: VMEntity
#StateObject
private var devicesDraggingManager = DraggingManager<VMDeviceInfo>()
// Currently displaying devices - different during dragging.
private var displayedDevices: [VMDeviceInfo] { devicesDraggingManager.entries ?? vmEntity.config.devices }
var body: some View {
Section("Devices") {
LazyVGrid(columns: [.init(.adaptive(minimum: 64, maximum: 64))], alignment: .leading, spacing: 20) {
Group {
ForEach(displayedDevices) { device in
Button(action: { configureDevice = device }) {
device.label
.draggable(
draggingManager: devicesDraggingManager,
entry: device,
originalEntries: $vmEntity.config.devices
)
}
}
Button(action: { configureNewDevice = true }, label: { Label("Add device", systemImage: "plus") })
}
.labelStyle(IconLabelStyle())
}
.coordinateSpace(name: devicesDraggingManager.coordinateSpaceID)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.buttonStyle(.plain)
}
}

NavigationView with Loading More in a List Crashes with Long Press only on iPad

I am creating a list that loads data when the user reaches the bottom of the list. I can crash the app when I load more elements and long-press an element within the list. The view is wrapped in a NavigationView and a NavigationLink. When the app crashes, you get EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0) with the thread 1 specialized saying "RandomAccessCollection<>.index(_:offsetBy:))". Looking into the EXC_BAD_INSTRUCTION I thought it could be force unwrapping, but I don't see anywhere in the code that could cause this issue.
The issue only occurs on an iPad and happens randomly. With WWDC being yesterday, I thought this would have been fixed, so we downloaded the beta for Xcode 12, and this error still occurs.
Here is the full code:
import UIKit
import SwiftUI
import Combine
struct ContentView: View {
var body: some View {
RepositoriesListContainer(viewModel: RepositoriesViewModel())
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
enum GithubAPI {
static let pageSize = 10
static func searchRepos(query: String, page: Int) -> AnyPublisher<[Repository], Error> {
let url = URL(string: "https://api.github.com/search/repositories?q=\(query)&sort=stars&per_page=\(Self.pageSize)&page=\(page)")!
return URLSession.shared
.dataTaskPublisher(for: url) // 1.
.tryMap { try JSONDecoder().decode(GithubSearchResult<Repository>.self, from: $0.data).items } // 2.
.receive(on: DispatchQueue.main) // 3.
.eraseToAnyPublisher()
}
}
struct GithubSearchResult<T: Codable>: Codable {
let items: [T]
}
struct Repository: Codable, Identifiable, Equatable {
let id: Int
let name: String
let description: String?
let stargazers_count: Int
}
class RepositoriesViewModel: ObservableObject {
#Published private(set) var state = State()
private var subscriptions = Set<AnyCancellable>()
// 2.
func fetchNextPageIfPossible() {
guard state.canLoadNextPage else { return }
GithubAPI.searchRepos(query: "swift", page: state.page)
.sink(receiveCompletion: onReceive,
receiveValue: onReceive)
.store(in: &subscriptions)
}
private func onReceive(_ completion: Subscribers.Completion<Error>) {
switch completion {
case .finished:
break
case .failure:
state.canLoadNextPage = false
}
}
private func onReceive(_ batch: [Repository]) {
state.repos += batch
state.page += 1
state.canLoadNextPage = batch.count == GithubAPI.pageSize
}
// 3.
struct State {
var repos: [Repository] = []
var page: Int = 1
var canLoadNextPage = true
}
}
struct RepositoriesListContainer: View {
#ObservedObject var viewModel: RepositoriesViewModel
var body: some View {
RepositoriesList(
repos: viewModel.state.repos,
isLoading: viewModel.state.canLoadNextPage,
onScrolledAtBottom: viewModel.fetchNextPageIfPossible
)
.onAppear(perform: viewModel.fetchNextPageIfPossible)
}
}
struct RepositoriesList: View {
// 1.
let repos: [Repository]
let isLoading: Bool
let onScrolledAtBottom: () -> Void // 2.
var body: some View {
NavigationView {
List {
reposList
if isLoading {
loadingIndicator
}
}
}
// .OnlyStackNavigationView()
}
private var reposList: some View {
ForEach(repos) { repo in
// 1.
RepositoryRow(repo: repo).onAppear {
// 2.
if self.repos.last == repo {
self.onScrolledAtBottom()
}
}
.onTapGesture {
print("TAP")
}
.onLongPressGesture {
print("LONG PRESS")
}
}
}
private var loadingIndicator: some View {
Spinner(style: .medium)
.frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center)
}
}
struct RepositoryRow: View {
let repo: Repository
var body: some View {
NavigationLink(destination: LandmarkDetail()){VStack {
Text(repo.name).font(.title)
Text("⭐️ \(repo.stargazers_count)")
repo.description.map(Text.init)?.font(.body)
}}
}
}
struct Spinner: UIViewRepresentable {
let style: UIActivityIndicatorView.Style
func makeUIView(context: Context) -> UIActivityIndicatorView {
let spinner = UIActivityIndicatorView(style: style)
spinner.hidesWhenStopped = true
spinner.startAnimating()
return spinner
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {}
}
struct LandmarkDetail: View {
var body: some View {
VStack {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
Spacer()
}
}
}

SwiftUI View - viewDidLoad()?

Trying to load an image after the view loads, the model object driving the view (see MovieDetail below) has a urlString. Because a SwiftUI View element has no life cycle methods (and there's not a view controller driving things) what is the best way to handle this?
The main issue I'm having is no matter which way I try to solve the problem (Binding an object or using a State variable), my View doesn't have the urlString until after it loads...
// movie object
struct Movie: Decodable, Identifiable {
let id: String
let title: String
let year: String
let type: String
var posterUrl: String
private enum CodingKeys: String, CodingKey {
case id = "imdbID"
case title = "Title"
case year = "Year"
case type = "Type"
case posterUrl = "Poster"
}
}
// root content list view that navigates to the detail view
struct ContentView : View {
var movies: [Movie]
var body: some View {
NavigationView {
List(movies) { movie in
NavigationButton(destination: MovieDetail(movie: movie)) {
MovieRow(movie: movie)
}
}
.navigationBarTitle(Text("Star Wars Movies"))
}
}
}
// detail view that needs to make the asynchronous call
struct MovieDetail : View {
let movie: Movie
#State var imageObject = BoundImageObject()
var body: some View {
HStack(alignment: .top) {
VStack {
Image(uiImage: imageObject.image)
.scaledToFit()
Text(movie.title)
.font(.subheadline)
}
}
}
}
We can achieve this using view modifier.
Create ViewModifier:
struct ViewDidLoadModifier: ViewModifier {
#State private var didLoad = false
private let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content.onAppear {
if didLoad == false {
didLoad = true
action?()
}
}
}
}
Create View extension:
extension View {
func onLoad(perform action: (() -> Void)? = nil) -> some View {
modifier(ViewDidLoadModifier(perform: action))
}
}
Use like this:
struct SomeView: View {
var body: some View {
VStack {
Text("HELLO!")
}.onLoad {
print("onLoad")
}
}
}
I hope this is helpful. I found a blogpost that talks about doing stuff onAppear for a navigation view.
Idea would be that you bake your service into a BindableObject and subscribe to those updates in your view.
struct SearchView : View {
#State private var query: String = "Swift"
#EnvironmentObject var repoStore: ReposStore
var body: some View {
NavigationView {
List {
TextField($query, placeholder: Text("type something..."), onCommit: fetch)
ForEach(repoStore.repos) { repo in
RepoRow(repo: repo)
}
}.navigationBarTitle(Text("Search"))
}.onAppear(perform: fetch)
}
private func fetch() {
repoStore.fetch(matching: query)
}
}
import SwiftUI
import Combine
class ReposStore: BindableObject {
var repos: [Repo] = [] {
didSet {
didChange.send(self)
}
}
var didChange = PassthroughSubject<ReposStore, Never>()
let service: GithubService
init(service: GithubService) {
self.service = service
}
func fetch(matching query: String) {
service.search(matching: query) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let repos): self?.repos = repos
case .failure: self?.repos = []
}
}
}
}
}
Credit to: Majid Jabrayilov
Fully updated for Xcode 11.2, Swift 5.0
I think the viewDidLoad() just equal to implement in the body closure.
SwiftUI gives us equivalents to UIKit’s viewDidAppear() and viewDidDisappear() in the form of onAppear() and onDisappear(). You can attach any code to these two events that you want, and SwiftUI will execute them when they occur.
As an example, this creates two views that use onAppear() and onDisappear() to print messages, with a navigation link to move between the two:
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView()) {
Text("Hello World")
}
}
}.onAppear {
print("ContentView appeared!")
}.onDisappear {
print("ContentView disappeared!")
}
}
}
ref: https://www.hackingwithswift.com/quick-start/swiftui/how-to-respond-to-view-lifecycle-events-onappear-and-ondisappear
I'm using init() instead. I think onApear() is not an alternative to viewDidLoad(). Because onApear is called when your view is being appeared. Since your view can be appear multiple times it conflicts with viewDidLoad which is called once.
Imagine having a TabView. By swiping through pages onApear() is being called multiple times. However viewDidLoad() is called just once.

Resources