How to fetch Link metadata in SwiftUI using LinkPresentation? - swift

According to WWDC20 and other articles it seems its quite easy to fetch the image url from a given url. Below is my starter code. That just lists a random list of urls and is supposed to fetch the imageurl for the rich link previews of the urls.
One simply fetches the metadata using the LPMetadataProvider. But i can't get it to show the image url. Does someone know how its done in SwiftUI?
import SwiftUI
import LinkPresentation
struct ExampleHTTPLinks: View {
var links = [ "https://www.google.com", "https://www.hotmail.com"]
let metadataProvider = LPMetadataProvider()
var body: some View {
List(links, id:\.self) { item in
HStack {
Text(item)
Image(systemName: "heart.fill")
metadataProvider.startFetchingMetadata(for: URL(string: item)!) { metadata, error in
if error != nil {
// The fetch failed; handle the error.
// Examples: server doesn't respond, is too slow, user doesn't have network.
return
}
let linkView = LPLinkView(metadata: metadata)
Image(linkView.image)
// Make use of the fetched metadata.
}
}
}
}
}
struct ExampleHTTPLinks_Previews: PreviewProvider {
static var previews: some View {
ExampleHTTPLinks()
}
}

Here's a working version:
class LinkViewModel : ObservableObject {
let metadataProvider = LPMetadataProvider()
#Published var metadata: LPLinkMetadata?
#Published var image: UIImage?
init(link : String) {
guard let url = URL(string: link) else {
return
}
metadataProvider.startFetchingMetadata(for: url) { (metadata, error) in
guard error == nil else {
assertionFailure("Error")
return
}
DispatchQueue.main.async {
self.metadata = metadata
}
guard let imageProvider = metadata?.imageProvider else { return }
imageProvider.loadObject(ofClass: UIImage.self) { (image, error) in
guard error == nil else {
// handle error
return
}
if let image = image as? UIImage {
// do something with image
DispatchQueue.main.async {
self.image = image
}
} else {
print("no image available")
}
}
}
}
}
struct MetadataView : View {
#StateObject var vm : LinkViewModel
var body: some View {
VStack {
if let metadata = vm.metadata {
Text(metadata.title ?? "")
}
if let uiImage = vm.image {
Image(uiImage: uiImage)
.resizable()
.frame(width: 100, height: 100)
}
}
}
}
struct ContentView: View {
var links = [ "https://www.google.com", "https://www.hotmail.com"]
let metadataProvider = LPMetadataProvider()
var body: some View {
List(links, id:\.self) { item in
HStack {
Text(item)
Image(systemName: "heart.fill")
MetadataView(vm: LinkViewModel(link: item))
}
}
}
}
LPMetadataProvider complains if you try to use it for multiple calls, so I've moved it to a view model.
The image is vended by an NSImageProvider -- you can see the loadObject call is what gets the UIImage out of it.
Note that you could use LPLinkView if you wanted to use the out-of-the-box presentation that Apple gives you. Because it's a UIView, to use it in SwiftUI, you'd have to wrap it with UIViewRepresentable:
struct LPLinkViewRepresented: UIViewRepresentable {
var metadata: LPLinkMetadata
func makeUIView(context: Context) -> LPLinkView {
return LPLinkView(metadata: metadata)
}
func updateUIView(_ uiView: LPLinkView, context: Context) {
}
}
struct MetadataView : View {
#StateObject var vm : LinkViewModel
var body: some View {
if let metadata = vm.metadata {
LPLinkViewRepresented(metadata: metadata)
} else {
EmptyView()
}
}
}
class LinkViewModel : ObservableObject {
let metadataProvider = LPMetadataProvider()
#Published var metadata: LPLinkMetadata?
init(link : String) {
guard let url = URL(string: link) else {
return
}
metadataProvider.startFetchingMetadata(for: url) { (metadata, error) in
guard error == nil else {
assertionFailure("Error")
return
}
DispatchQueue.main.async {
self.metadata = metadata
}
}
}
}

Related

Refresh remotely loaded image in SwiftUI

It's a lot of code and looks daunting, but it's pretty simple--I'm trying to load remote image, and when the image is clicked, I'd like to switch to the next image:
struct TestView: View {
#State var selectedIndex: Int = 0
#State var arrayOfImages: [String] = ["https://s3-media3.fl.yelpcdn.com/bphoto/_0bkRz0wln3URHevWORCkA/o.jpg", "https://s3-media2.fl.yelpcdn.com/bphoto/MDZXc4pDt5xUfXF0Rw6rMw/o.jpg", "https://s3-media3.fl.yelpcdn.com/bphoto/feYg35an2MilNK3dCwwqTQ/o.jpg"]
var body: some View {
RemoteImage(url: arrayOfImages[selectedIndex])
.scaledToFill()
.frame(width: 200, height: 200)
.clipped()
.onTapGesture {
selectedIndex += 1
}
}
}
struct RemoteImage: View {
private enum LoadState {
case loading, success, failure
}
private class Loader: ObservableObject {
var data = Data()
var state = LoadState.loading
init(url: String) {
guard let parsedURL = URL(string: url) else {
fatalError("Invalid URL: \(url)")
}
URLSession.shared.dataTask(with: parsedURL) { data, response, error in
if let data = data, data.count > 0 {
self.data = data
self.state = .success
} else {
self.state = .failure
}
DispatchQueue.main.async {
self.objectWillChange.send()
}
}.resume()
}
}
#StateObject private var loader: Loader
var loading: Image
var failure: Image
var body: some View {
selectImage()
.resizable()
}
init(url: String, loading: Image = Image(""), failure: Image = Image(systemName: "multiply.circle")) {
_loader = StateObject(wrappedValue: Loader(url: url))
self.loading = loading
self.failure = failure
}
private func selectImage() -> Image {
switch loader.state {
case .loading:
return loading
case .failure:
return failure
default:
if let image = UIImage(data: loader.data) {
return Image(uiImage: image)
} else {
return failure
}
}
}
}
Here's the problem: the image doesn't go to the next one when you tap on it. I think it's because the RemoteImage view isn't being reloaded, but I'm not sure how to fix. Any help is appreciated!
I think you are trying to do too much inside RemoteImage, in particular
declaring private #StateObject private var loader: Loader and all that derives from this.
Try this approach with #StateObject var loader = Loader() outside your RemoteImage.
Works for me.
struct ContentView: View {
var body: some View {
TestView()
}
}
struct TestView: View {
#StateObject var loader = Loader() // <-- here
#State var selectedIndex: Int = 0
#State var arrayOfImages: [String] = ["https://s3-media3.fl.yelpcdn.com/bphoto/_0bkRz0wln3URHevWORCkA/o.jpg", "https://s3-media2.fl.yelpcdn.com/bphoto/MDZXc4pDt5xUfXF0Rw6rMw/o.jpg", "https://s3-media3.fl.yelpcdn.com/bphoto/feYg35an2MilNK3dCwwqTQ/o.jpg"]
var body: some View {
RemoteImage(loader: loader) // <--- here
.scaledToFill()
.frame(width: 200, height: 200)
.clipped()
.onTapGesture {
selectedIndex += 1
if selectedIndex < arrayOfImages.count {
loader.load(url: arrayOfImages[selectedIndex]) // <-- here
} else {
//...
}
}
.onAppear {
loader.load(url: arrayOfImages[selectedIndex]) // <--- here
}
}
}
class Loader: ObservableObject {
var data = Data()
var state = LoadState.loading
func load(url: String) { // <--- here
guard let parsedURL = URL(string: url) else {
fatalError("Invalid URL: \(url)")
}
URLSession.shared.dataTask(with: parsedURL) { data, response, error in
if let data = data, data.count > 0 {
self.data = data
self.state = .success
} else {
self.state = .failure
}
DispatchQueue.main.async {
self.objectWillChange.send()
}
}.resume()
}
}
enum LoadState {
case loading, success, failure
}
struct RemoteImage: View {
#ObservedObject var loader: Loader // <--- here
var loading: Image = Image("")
var failure: Image = Image(systemName: "multiply.circle")
var body: some View {
selectImage().resizable()
}
private func selectImage() -> Image {
switch loader.state {
case .loading:
return loading
case .failure:
return failure
default:
if let image = UIImage(data: loader.data) {
return Image(uiImage: image)
} else {
return failure
}
}
}
}

Why is my function returning an empty array?

I am trying to call the results of this function in my SwiftUI view:
class GetMessages: ObservableObject {
let BASE_URL = "apicallurl.com"
#Published var messages = [Timestamp]()
func fetchMessages() {
guard let url = URL(string: BASE_URL) else { return }
URLSession.shared.dataTask(with: url) { (data, response, error) in
guard error == nil else {print(error!.localizedDescription); return }
let theData = try! JSONDecoder().decode([String: Timestamp].self, from: data!)
DispatchQueue.main.async {
self.messages = Array(theData.values)
}
}
.resume()
}
}
I am testing the output with a print statement in the onAppear:
struct HomeTab: View {
#StateObject var getMsgs = GetMessages()
var body: some View {
NavigationView {
VStack(spacing: 0) {
greeting.edgesIgnoringSafeArea(.top)
messages
Spacer()
}
.onAppear {
print(getMsgs.fetchMessages())
print(getMsgs.messages)
}
}.navigationBarHidden(true)
}
both print statements print () or []
But when i print print(self.messages) in my GetMessages class the data prints fine.
Why is it empty in my Hometab view?
When you use getMsgs.fetchMessages() it may take some times to fetch the results. Once the results are available
the messages of getMsgs in HomeTab will be updated, and this will trigger a view refresh,
because it is a #StateObject and is "monitored" by the view.
However you should not try to print(getMsgs.messages) before the results are available.
So try the following sample code:
struct HomeTab: View {
#StateObject var getMsgs = GetMessages()
var body: some View {
NavigationView {
List {
ForEach(getMsgs.messages, id: \.self) { msg in
Text("\(msg)")
}
}
.onAppear {
getMsgs.fetchMessages()
// no printing of getMsgs.messages here
}
}.navigationBarHidden(true)
}
}

SwiftUI ObservableObject with #Published array

The UI will update when the array is reassigned to a whole new array, but if I perform a remove on the array the UI will not update.
I was thinking maybe since it was still pointing to the same place in memory the UI wouldn’t be notified of the change. However, I added a didSet property observer to print every time the array was changed and the print statement executed on the remove.
I’m at a loss for how to debug further. Any help would be appreciated!
Code:
Here are the views. There is a movie list view which displays a grid of movie views
struct MovieListView: View {
#ObservedObject var viewModel: MovieListViewModel
init(viewModel: MovieListViewModel) {
self.viewModel = viewModel
}
var body: some View {
ZStack {
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(columns: [
GridItem(.flexible(minimum: 100, maximum: 200), alignment: .top),
GridItem(.flexible(minimum: 100, maximum: 200), alignment: .top)
], spacing: 12, content: {
ForEach(0 ..< viewModel.movies.count, id: \.self) { index in
viewModel.viewForMovie(viewModel.movies[index])
}
})
.id(UUID()) //For ignoring animation
}
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.blue))
.scaleEffect(1.5, anchor: .center)
.opacity(self.viewModel.loadingMovies ? 1 : 0)
}.onAppear { self.viewModel.loadMovies() }
}
}
struct MovieView: View {
#ObservedObject var viewModel: MovieViewModel
var onDelete: (Movie) -> Void
init(viewModel: MovieViewModel, onDelete: #escaping (Movie) -> Void) {
self.viewModel = viewModel
self.onDelete = onDelete
}
var body: some View {
TmdbMovieView(vm: self.viewModel.tmdbViewModel)
.contentShape(RoundedRectangle(cornerRadius: Constants.MovieViews.movieViewCornerRadius, style: .continuous)) //Prevents sharp edges on context menu
.contextMenu {
Button {
self.onDelete(viewModel.movie)
} label: {
Label("Remove from list", systemImage: Constants.Icons.remove)
}
}
}
}
Here's the movie list view model which specifies what view to use for the movies and exposes the movies from the movie list controller
class MovieListViewModel: ObservableObject {
let name: String
var movies: [Movie] {
return self.movieListController.movies
}
#Published var loadingMovies = false
let movieListController: MovieListController
var viewForMovie: (Movie) -> MovieView
init(name: String, movieListController: MovieListController, viewForMovie: #escaping (Movie) -> MovieView) {
self.name = name
self.movieListController = movieListController
self.viewForMovie = viewForMovie
}
func loadMovies() {
DispatchQueue.main.async {
self.loadingMovies = true
}
self.movieListController.getMovies(complete: onLoadMoviesComplete)
}
private func onLoadMoviesComplete(success: Bool) {
if !success {
//TODO: Handle load fail
}
DispatchQueue.main.async {
self.loadingMovies = false
}
}
func deleteMovie(movie: Movie) {
self.movieListController.delete(movie: movie, complete: onDeleteComplete)
}
private func onDeleteComplete(success: Bool) {
if success {
loadMovies() //TODO: Need this because movies already triggered a change, but the view won't update???
} else {
//TODO: Handle delete fail
}
}
}
Here's my view model factory that creates the view model instance and ties the delete callback on the movie view to the view model
class ViewModelFactory {
let movieListController = MovieListController(id: 1)
func makeMovieListViewModel() -> MovieListViewModel {
let viewModel = MovieListViewModel(
name: "Test",
movieListController: self.movieListController,
viewForMovie: { [unowned self] in
MovieView(viewModel: self.makeMovieViewModel(for: $0), onDelete: {_ in})
}
)
viewModel.viewForMovie = { [unowned self] in
MovieView(viewModel: self.makeMovieViewModel(for: $0), onDelete: viewModel.deleteMovie)
}
return viewModel
}
func makeMovieViewModel(for movie: Movie) -> MovieViewModel {
return MovieViewModel(movie: movie)
}
}
And here is the controller which handles actually hitting my api. The get movies function updates the movies array and the UI updates accordingly. However, the delete function does not cause the UI to update
class MovieListController: ObservableObject {
#Published var movies: [Movie] = []
private var id: Int
init(id: Int) {
self.id = id
}
func getMovies(complete: #escaping (Bool) -> Void) {
guard let moviesUrl = URL(string: "\(Constants.Urls.movieLists)/\(id)\(Constants.Urls.moviesPath)") else {
print("Invalid url...")
complete(false)
return
}
URLSession.shared.dataTask(with: moviesUrl) { data, response, error in
guard let data = data else { return }
do {
var decodedMovies = try JSONDecoder().decode([Movie].self, from: data)
decodedMovies.sort { $0.id < $1.id }
self.movies = decodedMovies
complete(true)
} catch {
print("Failed to decode: \(error)")
complete(false)
}
}.resume()
}
func delete(movie: Movie, complete: #escaping (Bool) -> Void) {
guard let deleteMovieUrl = URL(string: "\(Constants.Urls.movieLists)/\(self.id)\(Constants.Urls.moviesPath)/\(movie.id)") else {
print("Invalid url...")
complete(false)
return
}
var request = URLRequest(url: deleteMovieUrl)
request.httpMethod = "DELETE"
URLSession.shared.dataTask(with: request) { data, response, error in
guard let response = response as? HTTPURLResponse,
error == nil else { // check for fundamental networking error
print("error", error ?? "Unknown error")
complete(false)
return
}
guard (200 ... 299) ~= response.statusCode else { // check for http errors
print("statusCode should be 2xx, but is \(response.statusCode)")
print("response = \(response)")
complete(false)
return
}
if let idx = self.movies.firstIndex(where: { $0.id == movie.id }) {
self.movies.remove(at: idx)
}
complete(true)
}.resume()
}
}

Update view with observed objects of observed array in swiftui

I'm having an Image holder that would load the thumdnail on init and allow for download later on. My issue is that the view is not updated with the images after I load them. After pressing the load button for the second time, my first images are then displayed.
I'm having trouble finding the reason behind this behaviour.
The image holder :
class MyImage: ObservableObject {
private static let sessionProcessingQueue = DispatchQueue(label: "SessionProcessingQueue")
#Published var thumbnail: UIImage?
#Published var loaded: Bool
var fullName: String {
"\(folderName)/\(fileName)"
}
var onThumbnailSet: ((UIImage?) -> Void)
private var folderName: String
private var fileName: String
private var cancelableThumbnail: AnyCancellable?
private var thumbnailUrl: URL? {
return URL(string: "\(BASE_URL)/thumbnail/\(fullName)")
}
private var downloadUrl: URL? {
return URL(string: "\(BASE_URL)/download/\(fullName)")
}
init(folderName: String, fileName: String) {
self.folderName = folderName
self.fileName = fileName
self.loaded = false
self.loadThumbnail()
}
private func loadThumbnail() {
guard let requestUrl = thumbnailUrl else { fatalError() }
self.cancelableThumbnail = URLSession.shared.dataTaskPublisher(for: requestUrl)
.subscribe(on: Self.sessionProcessingQueue)
.map { UIImage(data: $0.data) }
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (suscriberCompletion) in
switch suscriberCompletion {
case .finished:
break
case .failure(let error):
print(error.localizedDescription)
}
}, receiveValue: { [weak self] (value) in
self?.objectWillChange.send()
self?.loaded.toggle()
self?.thumbnail = value
})
}
The view :
struct MyView: View {
#ObservedObject var imagesHolder: ImagesHolder = ImagesHolder()
var body: some View {
VStack {
Button(action: {
self.loadImages()
}, label: {
Text("Load images")
})
ForEach(imagesHolder.images, id: \.self) { image in
if image.loaded {
Image(uiImage: image.thumbnail!)
.frame(width: 600, height: 600)
} else {
Text("Not loaded")
}
}
}
}
private func loadImages() -> Void {
loadMediaList(
onLoadDone: { myImages in
myImages.forEach { image in
imagesHolder.append(image)
}
}
)
}
}
The observed object containing the array of loaded images :
class ImagesHolder: ObservableObject {
#Published var images: [MyImage] = []
func append(_ myImage: MyImage) {
objectWillChange.send()
images.append(myImage)
}
}
And finally my data loader :
func loadMediaList(onLoadDone: #escaping (([MyImage]) -> Void)) -> AnyCancellable {
let url = URL(string: "\(BASE_URL)/medias")
guard let requestUrl = url else { fatalError() }
return URLSession.shared.dataTaskPublisher(for: requestUrl)
.subscribe(on: Self.sessionProcessingQueue)
.map { parseJSON(data: $0.data) }
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (suscriberCompletion) in
switch suscriberCompletion {
case .finished:
break
case .failure(let error):
print(error.localizedDescription)
}
}, receiveValue: { images in
onLoadDone(images);
})
}
What I ended up doing and worked great for me was having a seperate view for the display of my Image like this :
struct MyImageView: View {
#ObservedObject var image: MyImage
init(image: MyImage) {
self.image = image
}
var body: some View {
if image.loaded {
Image(uiImage: image.thumbnail!)
.resizable()
} else {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.frame(width: 100, height: 100, alignment: .center)
}
}
}
struct MyView: View {
#ObservedObject var imagesHolder: ImagesHolder = ImagesHolder()
var body: some View {
VStack {
Button(action: {
self.loadImages()
}, label: {
Text("Load images")
})
ForEach(imagesHolder.images, id: \.self) { image in
MyImageView(image: image)
}
}
}
private func loadImages() -> Void {
loadMediaList(
onLoadDone: { myImages in
myImages.forEach { image in
imagesHolder.append(image)
}
}
)
}
}

SWIFTUI Observable Object Data Task only runs once?

I have an observable object class that downloads an image from a url to display:
class ImageLoader : ObservableObject {
var didChange = PassthroughSubject<Data, Never>()
var data = Data() {
didSet {
didChange.send(data)
}
}
init(urlString:String){
guard let url = URL(string: urlString) else {return}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else { return }
DispatchQueue.main.async {
self.data = data
print("imageloader1")
}
}
task.resume()
}
and I show it using:
struct ShowImage1: View {
#ObservedObject var imageLoader:ImageLoader
#State var image:UIImage = UIImage()
init(withURL url:String) {
imageLoader = ImageLoader(urlString:url)
}
var body: some View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.edgesIgnoringSafeArea(.top)
.onReceive(imageLoader.didChange) {
data in self.image = UIImage(data: data) ?? UIImage()
}
}
The problem I'm having is this is only capable of running once, If i click off the ShowImage1 view and then click back on to it, ImageLoader doesn't run again, and I'm left with a blank page.
How can I ensure that ImageLoader Runs every time the ShowImage1 view is accessed?
EDIT:
I access ShowImage1 like this:
struct PostCallForm: View {
var body: some View {
NavigationView {
Form {
Section {
Button(action: {
if true {
self.showImage1 = true
}
}){
Text("View Camera 1 Snapshot")
}.overlay(NavigationLink(destination: ShowImage1(withURL: "example.com/1.jpg"), isActive: self.$showImage1, label: {
EmptyView()
}))
}
}
Section {
Button(action: {
}){
Text("Submit")
}
}
}.disabled(!submission.isValid)
}
}
}
import SwiftUI
import Combine
class ImageLoader : ObservableObject {
var didChange = PassthroughSubject<Data, Never>()
var data = Data() {
didSet {
didChange.send(data)
}
}
func loadImage(urlString:String) {
guard let url = URL(string: urlString) else {return}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else { return }
DispatchQueue.main.async {
self.data = data
print("imageloader1")
}
}
task.resume()
}
}
struct ShowImage1Parent: View {
#State var url: String = ""
var sampleURLs: [String] = ["https://image.shutterstock.com/image-vector/click-here-stamp-square-grunge-600w-1510095275.jpg", "https://image.shutterstock.com/image-vector/certified-rubber-stamp-red-grunge-600w-1423389728.jpg", "https://image.shutterstock.com/image-vector/sample-stamp-square-grunge-sign-600w-1474408826.jpg" ]
var body: some View {
VStack{
Button("load-image", action: {
url = sampleURLs.randomElement()!
})
ShowImage1(url: $url)
}
}
}
struct ShowImage1: View {
#StateObject var imageLoader:ImageLoader = ImageLoader()
#State var image:UIImage = UIImage()
#Binding var url: String
var body: some View {
VStack{
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.edgesIgnoringSafeArea(.top)
.onReceive(imageLoader.didChange) {
data in self.image = UIImage(data: data) ?? UIImage()
}
.onChange(of: url, perform: { value in
imageLoader.loadImage(urlString: value)
})
}
}
}