Does LazyHStack (SwiftUI) release memory for views out of screen? - ios

I am trying to build a simple horizontal list of images. The images are loaded asynchronously, nothing fancy so far.
The main issue is that when scrolling and loading new images, the memory increases and never gets released. In case the images are really big, or the list of items very long, the app can crash while scrolling.
Shouldn't the LazyHStack do some memory management of views? I can not imagine this is not resolved so far, since it seems to me a very basic "feature".
The code (nothing special):
struct ThumbnailsCollectionView: View {
var imageService = ImageService.shared
#Binding var selectedIndex: Int
#State var image: UIImage?
var body: some View {
ScrollView([.horizontal]){
ScrollViewReader { reader in
LazyHStack(alignment: .bottom, spacing: 5){
ForEach((0...imageService.imageCount()-1), id: \.self) { index in
ThumbnailImageView(index: index)
}
}
}.padding(.vertical, 10)
}
}
}
struct ThumbnailImageView: View {
var imageService = ImageService.shared
var index: Int
#State var image: UIImage? = nil
var body: some View {
return Button(action: {
}) {
if let uiImage = image {
Image(uiImage: uiImage).resizable()
.aspectRatio(contentMode: .fit)
.zIndex(5)
}
else {
Rectangle().frame(width: 50, height: 50, alignment: .center)
}
}
.onAppear() {
imageService.thumbnail(at: index) { (image) in
self.image = image
}
}
}
}
ImageServiceCache
class ImageService {
static let shared = ImageService()
var thumbnailCache: NSCache<NSNumber, UIImage> = {
let cache = NSCache<NSNumber,UIImage>()
cache.countLimit = 10
return cache
}()
var urls:[URL] = {
var urls = [URL]()
for i in 0...100 {
let index = i % 4
urls.append(Bundle.main.url(forResource: "\(index)", withExtension: "jpg")!)
}
return urls
}()
func imageCount() -> Int {
return urls.count
}
func thumbnail(at index: Int, completion:#escaping (UIImage)->()) {
if let imageFromCache = thumbnailCache.object(forKey: NSNumber(integerLiteral: index)) {
return completion(imageFromCache)
}
DispatchQueue.global().async {
let image = UIImage(contentsOfFile: self.urls[index].path)!
self.thumbnailCache.setObject(image, forKey: NSNumber(integerLiteral: index))
DispatchQueue.main.async {
completion(image)
}
}
}
}

Related

how to make A SwiftUI Image Gallery/Slideshow With Auto Scrolling in SwiftUI?

How can I make a slider when my Data is coming from API? I am using
this(below code) for static images work fine but whenever I try to
use API data then my code does not work.
How to Set the Marquee in this images.
This is My code
public struct MagazineModel: Decodable {
public let magzineBanners: [MagzineBanner]
}
public struct MagzineBanner: Decodable, Identifiable {
public let id: Int
public let url: String
}
This is My View Model
//View Model for Magazines and showing Details
class MagazineBannerVM: ObservableObject{
#Published var datas = [MagzineBanner]()
let url = "ApiUrl"
init() {
getData(url: url)
}
func getData(url: String) {
guard let url = URL(string: url) else { return }
URLSession.shared.dataTask(with: url) { (data, _, _) in
if let data = data {
do {
let results = try JSONDecoder().decode(MagazineModel.self, from: data)
DispatchQueue.main.async {
self.datas = results.magzineBanners
}
}
catch {
print(error)
}
}
}.resume()
}
}
struct MagazineBannerView: View{
#ObservedObject var list = MagazineBannerVM()
public let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
#State var currentIndex = 0
#State var totalImages = 2
var body: some View{
ScrollView(.horizontal) {
GeometryReader { proxy in
TabView(selection: $currentIndex) {
HStack{
ForEach(list.datas, id: \.id){ item in
Group{
AsyncImage(url: URL(string: item.url)){ image in
image
.resizable()
.frame(width:UIScreen.main.bounds.width, height: 122)
}placeholder: {
Image("logo_gray").resizable()
.frame(width:UIScreen.main.bounds.width, height: 122)
}
}
}
}
}
.tabViewStyle(PageTabViewStyle())
.onReceive(timer, perform: { _ in
withAnimation{
currentIndex = currentIndex < totalImages ? currentIndex + 1: 0
}
})
}
}
}
}
I want to change images after every 2 seconds and every images has
full width as the screen width
And it is showing the half of screen width and showing both images in
single view

How to handle loading screens in SwiftUI?

I am trying to implement a simple 3-screen application on SwiftUI. The 1st screen allows the user to select a photo, the 2nd must show the loading screen while the photo is analyzed, and the 3rd shows analysis results. Currently, my application doesn't show the loading screen at all and just jumps to the 3rd screen.
ContentView.swift:
struct ContentView: View {
#State var image: Image = Image("MyImageName")
#State var tiles: [Tile] = []
#State var isSelectionView: Bool = true
#State var isLoadingView: Bool = false
#State var isAdjustmentView: Bool = false
var body: some View {
if (isSelectionView) {
SelectionView(image: $image, isSelectionView: $isSelectionView, isLoadingView: $isLoadingView)
}
if (isLoadingView) {
LoadingMLView(image: $image, tiles: $tiles, isLoadingView: $isLoadingView, isAdjustmentView: $isAdjustmentView)
}
if (isAdjustmentView) {
AdjustmentView(tiles: $tiles)
}
}}
SelectionView.swift:
struct SelectionView: View {
#State var showCaptureImageView = false
#State var showPopover = false
#State var sourceType: UIImagePickerController.SourceType = .camera
#State var imageSelected = false
#Binding var image: Image
#Binding var isSelectionView: Bool
#Binding var isLoadingView: Bool
var body: some View {
VStack() {
if (!showCaptureImageView && !imageSelected) {
Spacer()
Button(action: {
showPopover.toggle()
}) {
Text("Choose photo")
.frame(width: 100, height: 100)
.foregroundColor(Color.white)
.background(Color.blue)
.clipShape(Circle())
}
.confirmationDialog("Select location", isPresented: $showPopover) {
Button("Camera") {
sourceType = .camera
showPopover.toggle()
showCaptureImageView.toggle()
}
Button("Photos") {
sourceType = .photoLibrary
showPopover.toggle()
showCaptureImageView.toggle()
}
}
} else if (showCaptureImageView) {
CaptureImageView(isShown: $showCaptureImageView, image: $image, sourceType: $sourceType)
} else {
ActivityIndicator()
.frame(width: 200, height: 200)
.foregroundColor(.blue)
}
}
.onChange(of: image) { image in
showCaptureImageView.toggle()
imageSelected.toggle()
isSelectionView.toggle()
isLoadingView.toggle()
}
}}
LoadingMLView.swift:
struct LoadingMLView: View {
#Binding var image: Image
#Binding var tiles: [Tile]
#Binding var isLoadingView: Bool
#Binding var isAdjustmentView: Bool
var body: some View {
ActivityIndicator()
.frame(width: 200, height: 200)
.foregroundColor(.blue)
.onAppear {
do {
let model = try VNCoreMLModel(for: MyModel().model)
let request = VNCoreMLRequest(model: model, completionHandler: myResultsMethod)
let uiImage: UIImage = image.asUIImage()
guard let ciImage = CIImage(image: uiImage) else {
fatalError("Unable to create \(CIImage.self) from \(image).")
}
let handler = VNImageRequestHandler(ciImage: ciImage)
do {
try handler.perform([request])
} catch {
print("Something went wrong!")
}
func myResultsMethod(request: VNRequest, error: Error?) {
guard let results = request.results as? [VNRecognizedObjectObservation]
else { fatalError("error") }
for result in results {
let tile = Tile(name: result.labels[0].identifier)
tiles.append(tile)
}
isLoadingView.toggle()
isAdjustmentView.toggle()
}
} catch {
print("Error:", error)
}
}
}}
No matter how I change the toggle between screens, it just doesn't seem reactive and the views don't switch momentarily. What am I doing wrong? Is there a proper way to show loading screen right after the image is selected?
Seems like you're missing an else to avoid rendering the screens you don't want:
var body: some View {
if (isSelectionView) {
SelectionView(image: $image, isSelectionView: $isSelectionView, isLoadingView: $isLoadingView)
} else if (isLoadingView) {
LoadingMLView(image: $image, tiles: $tiles, isLoadingView: $isLoadingView, isAdjustmentView: $isAdjustmentView)
} else if (isAdjustmentView) {
AdjustmentView(tiles: $tiles)
}
}}
But, in general what you want is also called "state machine" - essentially your app can be in one of 3 possible states: selection, loading, adjustment, so you should have an enum #State to represent the current state, instead of the 3 bool values:
struct ContentView: View {
// ...
enum AppState { case selection, loading, adjustment }
#State private var state = AppState.selection
var body: some View {
switch state {
case .selection:
SelectionView(image: $image, state: $state)
case .loading:
LoadingMLView(image: $image, tiles: $tiles, state: $state)
case .adjustment:
AdjustmentView(tiles: $tiles)
}
}
When you want to switch to the next screen, just change state value...
Turns out, the main performance issue was caused by the image type conversion:
let uiImage: UIImage = image.asUIImage()
After passing the image as UIImage in the first place, the app started working fast, so there was no need to handle the loading in the first place.

How does SwiftUI know which view's needed to redraw or not?

I have a question about process that SwiftUI does.
This code below is from a Stanford cs193p lecture.
// View
struct ContentView: View {
#ObservedObject var viewModel: EmojiMemoryGame
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 65, maximum: 100))]) {
// Set break point 1
ForEach(viewModel.cards) { card in
CardView(card: card)
.aspectRatio(2/3, contentMode: .fit)
.onTapGesture {
viewModel.choose(card)
}
}
}
}
.foregroundColor(.red)
.padding(.horizontal)
}
}
struct CardView: View {
let card: MemoryGame<String>.Card
var body: some View {
// Set break point 2
ZStack {
let shape = RoundedRectangle(cornerRadius: 20)
if card.isFaceUp {
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: 3)
Text(card.content).font(.largeTitle)
} else if card.isMatched {
shape.opacity(0)
} else {
shape.fill()
}
}
}
}
// ViewModel
class EmojiMemoryGame: ObservableObject {
#Published private var model: MemoryGame<String> = EmojiMemoryGame.createMemoryGame()
static let emojis = ["๐Ÿ‘", "๐Ÿ–ฅ", "โ™ฅ๏ธ", "โ™ฆ๏ธ", "โ™ฃ๏ธ", "โ™ ๏ธ", "๐ŸŽ", "โŒ", "๐Ÿง‘๐Ÿปโ€๐Ÿ’ป", "๐Ÿ’ผ",
"รท", "โœ•", "โˆš", "๐Ÿ˜ญ", "โžก๏ธ", "๐Ÿ™Œ", "๐Ÿ†š", "๐Ÿ…ถ", "๐Ÿ“", "๐Ÿคœ"]
static func createMemoryGame() -> MemoryGame<String> {
return MemoryGame<String>(numberOfPairsOfCards: EmojiMemoryGame.emojis.count) { pairIndex in
EmojiMemoryGame.emojis[pairIndex]
}
}
var cards: [MemoryGame<String>.Card] {
return model.cards
}
func choose(_ card: MemoryGame<String>.Card) {
model.choose(card)
}
}
// Model
struct MemoryGame<CardContent: Equatable> {
private(set) var cards: [Card]
private var indexOfTheOneAndOnlyFaceUpCard: Int?
init(numberOfPairsOfCards: Int, createCardContent: (Int) -> CardContent) {
cards = []
for pairIndex in 0..<numberOfPairsOfCards {
let content = createCardContent(pairIndex)
cards.append(Card(content: content))
cards.append(Card(content: content))
}
}
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }),
!cards[chosenIndex].isFaceUp,
!cards[chosenIndex].isMatched
{
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
indexOfTheOneAndOnlyFaceUpCard = nil
} else {
for index in cards.indices {
cards[index].isFaceUp = false
}
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
cards[chosenIndex].isFaceUp.toggle()
}
}
struct Card: Identifiable {
let id: UUID = UUID()
var isFaceUp: Bool = false
var isMatched: Bool = false
var content: CardContent
}
}
As far as I know, viewModel.choose(card) is called, then some Card in cards(in MemoryGame) is changed. And next model(in EmojiMemoryGame) notices that I was changed, so finally ContentView will redraw the body(specifically some part that is depending on viewModel).
So I thought all CardView is going to be redrawn, but it's not. They are redrawing CardView only that has changed.
To double check, I set the break point like the comment in code above. It turns out, first break point came up for every card, but second break point just came up for just the card that had changed before.
Why does this happen like that?
Is it just SwiftUI's power?

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)
}
}

SwiftUI #ObservedObject does not get updated on Async fetch of the image

I have an app that fetches a list of items with images URL's from remote API, and then it has to fetch an image per item from given url inside that item's position.
The problem is that when scrolling up and down and thus removing list items from view and moving them back into view they do show up. However on initial load they stay in "loading" status forever until moved out and in.
My code:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var artObjectStore: ArtObjectStore
#State private var pageCount = 1
#State private var tappedLink: String? = nil
#Environment(\.imageCache) var cache: ImageCache
var body: some View {
NavigationView {
Form {
Section(header: Text("Art")) {
List {
ForEach(artObjectStore.artObjects, id: \.self) { artObject in
self.link(for: artObject)
}
Button(action: loadMore) {
Text("")
}
.onAppear {
DispatchQueue.global(qos: .background).asyncAfter(deadline: DispatchTime(uptimeNanoseconds: 10)) {
self.loadMore()
}
}
}
}
}
.navigationBarTitle("Art objects")
}
.onAppear(perform: loadMore)
}
func loadMore() {
pageCount += 1
artObjectStore.loadMore(pageCount)
}
private func link(for artObject: ArtObject) -> some View {
let selection = Binding(get: { self.tappedLink },
set: {
UIApplication.shared.endEditing()
self.tappedLink = $0
})
return NavigationLink(destination: DetailView(artObject: artObject, cache: self.cache),
tag: artObject.id,
selection: selection) {
HStack(alignment: .center) {
VStack(alignment: .leading){
Text("\(artObject.title)").font(.system(size: 12))
Text("\(artObject.principalOrFirstMaker)").font(.system(size: 9)).foregroundColor(.gray)
}
Spacer()
AsyncImage(
url: URL(string: artObject.headerImage.url)!,
cache: self.cache,
width: 200,
height: 50
)
}
}
}
}
extension UIApplication {
func endEditing() {
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Image container:
import SwiftUI
import Combine
import Foundation
class ImageLoader: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
private var cancellable: AnyCancellable?
#Published var image: UIImage? {
willSet {
objectWillChange.send()
}
}
private let url: URL
private var cache: ImageCache?
init(url: URL, cache: ImageCache? = nil) {
self.url = url
self.cache = cache
}
deinit {
cancellable?.cancel()
}
private func cache(_ image: UIImage?) {
image.map { cache?[url] = $0 }
}
func load() {
if let image = cache?[url] {
self.image = image
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data) }
.replaceError(with: nil)
.handleEvents(receiveOutput: { [weak self] in self?.cache($0) })
.receive(on: DispatchQueue.main)
.assign(to: \.image, on: self)
}
func cancel() {
cancellable?.cancel()
}
}
struct AsyncImage: View {
#ObservedObject private var loader: ImageLoader
private let width: CGFloat?
private let height: CGFloat?
#State var spin = false
init(url: URL, cache: ImageCache? = nil, width: CGFloat? = nil, height: CGFloat? = nil) {
loader = ImageLoader(url: url, cache: cache)
self.width = width
self.height = height
}
var body: some View {
image
.onAppear(perform: loader.load)
.onDisappear(perform: loader.cancel)
}
private var image: some View {
Group {
if loader.image != nil {
Image(uiImage: loader.image!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: width, height: height)
} else {
Image("loadingCircle")
.resizable()
.frame(width: 20, height: 20)
.rotationEffect(.degrees(spin ? 360 : 0))
.animation(Animation.linear(duration: 0.8).repeatForever(autoreverses: false))
.onAppear() {
self.spin.toggle()
}
}
}
}
}
protocol ImageCache {
subscript(_ url: URL) -> UIImage? { get set }
}
struct TemporaryImageCache: ImageCache {
private let cache = NSCache<NSURL, UIImage>()
subscript(_ key: URL) -> UIImage? {
get { cache.object(forKey: key as NSURL) }
set { newValue == nil ? cache.removeObject(forKey: key as NSURL) : cache.setObject(newValue!, forKey: key as NSURL) }
}
}
struct ImageCacheKey: EnvironmentKey {
static let defaultValue: ImageCache = TemporaryImageCache()
}
extension EnvironmentValues {
var imageCache: ImageCache {
get { self[ImageCacheKey.self] }
set { self[ImageCacheKey.self] = newValue }
}
}
I did try to add willSet on the image, that doesn't seem to work. Can you help me?
All below tested with Xcode 11.4 / iOS 13.4
Modified worked AsyncImage
I changed Group to VStack and it started updating
private var image: some View {
VStack { // << here !!
if loader.image != nil {
Modified worked image loader
class ImageLoader: ObservableObject {
#Published var image: UIImage?
private var cancellable: AnyCancellable?
private let url: URL
private var cache: ImageCache?
init(url: URL, cache: ImageCache? = nil) {
self.url = url
self.cache = cache
}
deinit {
cancellable?.cancel()
}
private func cache(_ image: UIImage?) {
self.image = image
image.map { cache?[url] = $0 }
}
func load() {
if let image = cache?[url] {
self.image = image
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data) }
.receive(on: DispatchQueue.main)
.replaceError(with: nil)
.sink(receiveValue: { [weak self] in self?.cache($0) })
}
func cancel() {
cancellable?.cancel()
}
}

Resources