I am using LPLinkView from LinkPresentation module to present rich links in my app. But when I try to change the background color for the LPLinkView it's rendered as below.
When I tried changing the backgroundColor of the subviews of the LPLinkView, there is no element in the array returned from UIView's subviews property. Here is what I tried
let linkView = LPLinkView(metadata: metadata)
linkView.backgroundColor = .red
linkView.subviews.forEach { $0.backgroundColor = .red}
We can't change the design, but we can get the contents from it and create our own view.
import SwiftUI
import LinkPresentation
struct LinkModel { let image: Image, title: String?, linkHost: String?, link: URL? }
class LinksDataModel: ObservableObject {
static func fetchMetadata(for url: URL, completion: #escaping (LinkModel?) -> Void) {
let metadataProvider = LPMetadataProvider()
metadataProvider.startFetchingMetadata(for: url) { (metadata, error) in
if let metadata = metadata {
// load image
metadata.imageProvider?.loadObject(ofClass: UIImage.self, completionHandler: { image, err in
if let uiImage: UIImage = image as? UIImage {
let image = Image(uiImage: uiImage)
completion(LinkModel(image: image, title: metadata.title,
linkHost: metadata.url?.host, link: metadata.url))
} else { completion(nil) }
})
} else { completion(nil) }
}
}
}
You can call the function onAppear
#State private var linkModel: LinkModel? = nil
if let model = linkModel {
LinkView(model: model)
}
.onAppear {
LinksDataModel.fetchMetadata(for: url) { linkModel in
if let model = linkModel {
DispatchQueue.main.async { self.linkModel = model }
}
}
}
Related
I'm creating simple CRUD operations in an app and I came across a hairy quirk, given SwiftUI's Image design.
I'm trying to upload an image to Firebase, except I need to convert an Image to a UIImage, and further into Data.
Here's my code:
Image Uploader
import UIKit
import Firebase
import FirebaseStorage
import SwiftUI
struct ImageUploader {
static func uploadImage(with image: Data, completion: #escaping(String) -> Void) {
let imageData = image
let fileName = UUID().uuidString
let storageRef = Storage.storage().reference(withPath: "/profile_images/\(fileName)")
storageRef.putData(imageData, metadata: nil) { (metadata, error) in
if let error = error {
print("Error uploading image to Firebase: \(error.localizedDescription)")
return
}
print("Image successfully uploaded to Firebase!")
}
storageRef.downloadURL { url, error in
guard let imageUrl = url?.absoluteString else { return }
completion(imageUrl)
}
}
}
View Model
import SwiftUI
import Firebase
func uploadProfileImage(with image: Data) {
guard let uid = tempCurrentUser?.uid else { return }
let imageData = image
ImageUploader.uploadImage(with: imageData) { imageUrl in
Firestore.firestore().collection("users").document(uid).updateData(["profileImageUrl":imageUrl]) { _ in
print("Successfully updated user data")
}
}
}
ImagePicker
import SwiftUI
import PhotosUI
#MainActor
class ImagePicker: ObservableObject {
#Published var image: Image?
#Published var imageSelection: PhotosPickerItem? {
didSet {
if let imageSelection {
Task {
try await loadTransferrable(from: imageSelection)
}
}
}
}
func loadTransferrable(from imageSelection: PhotosPickerItem?) async throws {
do {
if let data = try await imageSelection?.loadTransferable(type: Data.self) {
if let uiimage = UIImage(data: data) {
self.image = Image(uiImage: uiimage)
}
}
} catch {
print("Error: \(error.localizedDescription)")
}
}
}
View
if let image = imagePicker.image {
Button {
viewModel.uploadProfileImage(with: image)
} label: {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.frame(width: 340.0, height: 50.0)
.background(.blue)
.clipShape(Capsule())
.padding()
}
.shadow(radius: 10.0)
.padding(24.0)
}
As you can see, I have a problem in the view: Cannot convert value of type 'Image' to expected argument type 'Data', which makes sense. The images are all of type Data.
How can I do it?
don't use #Published var image: Image? in your class ImagePicker: ObservableObject, Image is a View and is for use in other Views.
Use #Published var image: UIImage? and adjust your code accordingly.
Then use image.pngData() to get the Data to upload.
I am trying to display rich links in a SwiftUI List and no matter what I try, I can't seem to be able to change the size of the link view (UIViewRepresentable) on screen.
Is there a minimum size for a particular link? And how can I get it. Adding .aspectRatio and clipped() will respect size but the link is heavily clipped. Not sure why the link will not adjust aspectRatio to fit view.
Some of the following code is sourced from the following tutorial:
https://www.appcoda.com/linkpresentation-framework/
I am using the following UIViewRepresentable for the LinkView:
import SwiftUI
import LinkPresentation
struct LinkViewRepresentable: UIViewRepresentable {
typealias UIViewType = LPLinkView
var metadata: LPLinkMetadata?
func makeUIView(context: Context) -> LPLinkView {
guard let metadata = metadata else { return LPLinkView() }
let linkView = LPLinkView(metadata: metadata)
return linkView
}
func updateUIView(_ uiView: LPLinkView, context: Context) {
}
}
And my view with List is:
import SwiftUI
import LinkPresentation
struct ContentView: View {
#ObservedObject var linksViewModel = LinksViewModel()
var links: [(String, String)] = [("https://www.apple.com", "1"), ("https://www.stackoverflow.com", "2")]
var body: some View {
ScrollView(.vertical) {
LazyVStack {
ForEach(links, id: \.self.1) { link in
VStack {
Text(link.0)
.onAppear {
linksViewModel.getLinkMetadata(link: link)
}
if let richLink = linksViewModel.links.first(where: { $0.id == link.1 }) {
if let metadata = richLink.metadata {
if metadata.url != nil {
LinkViewRepresentable(metadata: metadata)
.frame(width: 200) // setting frame dimensions here has no effect
}
}
}
}
}
}
.padding()
}
}
}
Setting the frame of the view or contentMode(.fit) or padding or anything else I've tried does not change the size of the frame of the LinkViewRepresentable. I have tried sizeToFit in the representable on update and no luck. Is it possible to control the size of the representable view here?
Here are additional Files:
import Foundation
import LinkPresentation
class LinksViewModel: ObservableObject {
#Published var links = [Link]()
init() {
loadLinks()
}
func createLink(with metadata: LPLinkMetadata, id: String) {
let link = Link()
link.id = id
link.metadata = metadata
links.append(link)
saveLinks()
}
fileprivate func saveLinks() {
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: links, requiringSecureCoding: true)
guard let docDirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
try data.write(to: docDirURL.appendingPathComponent("links"))
print(docDirURL.appendingPathComponent("links"))
} catch {
print(error.localizedDescription)
}
}
fileprivate func loadLinks() {
guard let docDirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let linksURL = docDirURL.appendingPathComponent("links")
if FileManager.default.fileExists(atPath: linksURL.path) {
do {
let data = try Data(contentsOf: linksURL)
guard let unarchived = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? [Link] else { return }
links = unarchived
} catch {
print(error.localizedDescription)
}
}
}
func fetchMetadata(for link: String, completion: #escaping (Result<LPLinkMetadata, Error>) -> Void) {
guard let uRL = URL(string: link) else { return }
let metadataProvider = LPMetadataProvider()
metadataProvider.startFetchingMetadata(for: uRL) { (metadata, error) in
if let error = error {
print(error)
completion(.failure(error))
return
}
if let metadata = metadata {
completion(.success(metadata))
}
}
}
func getLinkMetadata(link: (String, String)) {
for storedLink in self.links {
if storedLink.id != link.1 {
return
}
}
do {
let detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector.matches(in: link.0, options: [], range: NSRange(location: 0, length: link.0.utf16.count))
if let match = matches.first {
guard let range = Range(match.range, in: link.0) else { return }
let uRLString = link.0[range]
self.fetchMetadata(for: String(uRLString)) { result in
self.handleLinkFetchResult(result, link: link)
}
}
} catch {
print(error)
}
}
private func handleLinkFetchResult(_ result: Result<LPLinkMetadata, Error>, link: (String, String)) {
DispatchQueue.main.async {
switch result {
case .success(let metadata):
self.createLink(with: metadata, id: link.1)
case .failure(let error):
print(error.localizedDescription)
}
}
}
}
And Link Class:
import Foundation
import LinkPresentation
class Link: NSObject, NSSecureCoding, Identifiable {
var id: String?
var metadata: LPLinkMetadata?
override init() {
super.init()
}
// MARK: - NSSecureCoding Requirements
static var supportsSecureCoding = true
func encode(with coder: NSCoder) {
guard let id = id, let metadata = metadata else { return }
coder.encode(id, forKey: "id")
coder.encode(metadata as NSObject, forKey: "metadata")
}
required init?(coder: NSCoder) {
id = coder.decodeObject(forKey: "id") as? String
metadata = coder.decodeObject(of: LPLinkMetadata.self, forKey: "metadata")
}
}
This is what I get:
The solution that worked for me was subclassing the linkView overriding the intrinsic content size. Thanks to user1046037's comment, using super.intrinsicContentSize.height will enable it to work dynamically.
import SwiftUI
import LinkPresentation
class CustomLinkView: LPLinkView {
override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) }
}
struct LinkViewRepresentable: UIViewRepresentable {
typealias UIViewType = CustomLinkView
var metadata: LPLinkMetadata?
func makeUIView(context: Context) -> CustomLinkView {
guard let metadata = metadata else { return CustomLinkView() }
let linkView = CustomLinkView(metadata: metadata)
return linkView
}
func updateUIView(_ uiView: CustomLinkView, context: Context) {
}
}
I'm building an app that needs an image slideshow as the background of the welcome controller. My plan is to import images into a folder in Firebase Storage, set a Service function to download the folder's images and append to a model, then populate the controller's collection view cell with the images. Am I on the correct path to do an image slideshow? Thanks.
// BackgroundImage Model
struct BackgroundImage {
let backgroundImageUrl: String
init(dictionary: [String : Any]) {
self.backgroundImageUrl = dictionary["backgroundImageUrl"] as? String ?? ""
}
}
// Service
struct BgImgs {
let backgroundImage: UIImage
}
static func fetchBackgroundImages(bgImgs: BgImgs, completion: #escaping([BackgroundImage]) -> Void) {
var backgroundImages = [BackgroundImage]()
guard let imageData = bgImgs.backgroundImage.jpegData(compressionQuality: 0.3) else { return }
let filename = NSUUID().uuidString
let storageRef = STORAGE_REF.reference(withPath: "/background_images/\(filename)")
storageRef.putData(imageData, metadata: nil) { (meta, error) in
storageRef.downloadURL { (url, error) in
guard let backgroundImageUrl = url?.absoluteString else { return }
let values = ["backgroundImageUrl" : backgroundImageUrl]
let bgImages = BackgroundImage(dictionary: values)
backgroundImages.append(bgImages)
completion(backgroundImages)
}
}
}
// WelcomeController
private var backgroundImages = [BackgroundImage]()
func fetchBackgroundImages() {
Service.fetchBackgroundImages(bgImgs: backgroundImages) { backgroundImages in
self.backgroundImages = backgroundImages
}
}
You'll have to reload the UICollectionView after fetchBackgroundImages fetch is done.
func fetchBackgroundImages() {
Service.fetchBackgroundImages(bgImgs: backgroundImages) { backgroundImages in
self.backgroundImages = backgroundImages
self.collectionView.reloadData()
}
}
I want to display an image from a url retrieved in json in my list. How would I Do so?
I tried just calling image and entering the url, but it just shows the space for the image, but not the actual image.
var body: some View {
NavigationView {
List {
TextField("Search for Meme by name", text: self.$searchItem)
ForEach(viewModel.memes) { meme in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(meme.name).font(.headline).lineLimit(nil)
Image(meme.url).resizable().frame(width: 100, height: 100)
}
}
}
}
.navigationBarTitle("All Memes")
}.onAppear {
self.viewModel.fetchAllMemes()
}
}
Make your own view that has its own ObservableObject that downloads (and optionally caches) the image. Here is an example:
import SwiftUI
import Combine
import UIKit
class ImageCache {
enum Error: Swift.Error {
case dataConversionFailed
case sessionError(Swift.Error)
}
static let shared = ImageCache()
private let cache = NSCache<NSURL, UIImage>()
private init() { }
static func image(for url: URL) -> AnyPublisher<UIImage?, ImageCache.Error> {
guard let image = shared.cache.object(forKey: url as NSURL) else {
return URLSession
.shared
.dataTaskPublisher(for: url)
.tryMap { (tuple) -> UIImage in
let (data, _) = tuple
guard let image = UIImage(data: data) else {
throw Error.dataConversionFailed
}
shared.cache.setObject(image, forKey: url as NSURL)
return image
}
.mapError({ error in Error.sessionError(error) })
.eraseToAnyPublisher()
}
return Just(image)
.mapError({ _ in fatalError() })
.eraseToAnyPublisher()
}
}
class ImageModel: ObservableObject {
#Published var image: UIImage? = nil
var cacheSubscription: AnyCancellable?
init(url: URL) {
cacheSubscription = ImageCache
.image(for: url)
.replaceError(with: nil)
.receive(on: RunLoop.main, options: .none)
.assign(to: \.image, on: self)
}
}
struct RemoteImage : View {
#ObservedObject var imageModel: ImageModel
init(url: URL) {
imageModel = ImageModel(url: url)
}
var body: some View {
imageModel
.image
.map { Image(uiImage:$0).resizable() }
?? Image(systemName: "questionmark").resizable()
}
}
In SwiftUI there are some .init methods to create an Image but none of them admits a block or any other way to load an UIImage from network/cache...
I am using Kingfisher to load images from network and cache inside a list row, but the way to draw the image in the view is to re-render it again, which I would prefer to not do. Also, I am creating a fake image(only coloured) as placeholder while the image gets fetched.
Another way would be to wrap all inside a custom view and only re-render the wrapper. But I haven't tried yet.
This sample is working right now.
Any idea to improve the current one will be great
Some view using the loader
struct SampleView : View {
#ObjectBinding let imageLoader: ImageLoader
init(imageLoader: ImageLoader) {
self.imageLoader = imageLoader
}
var body: some View {
Image(uiImage: imageLoader.image(for: "https://url-for-image"))
.frame(width: 128, height: 128)
.aspectRatio(contentMode: ContentMode.fit)
}
}
import UIKit.UIImage
import SwiftUI
import Combine
import class Kingfisher.ImageDownloader
import struct Kingfisher.DownloadTask
import class Kingfisher.ImageCache
import class Kingfisher.KingfisherManager
class ImageLoader: BindableObject {
var didChange = PassthroughSubject<ImageLoader, Never>()
private let downloader: ImageDownloader
private let cache: ImageCache
private var image: UIImage? {
didSet {
dispatchqueue.async { [weak self] in
guard let self = self else { return }
self.didChange.send(self)
}
}
}
private var task: DownloadTask?
private let dispatchqueue: DispatchQueue
init(downloader: ImageDownloader = KingfisherManager.shared.downloader,
cache: ImageCache = KingfisherManager.shared.cache,
dispatchqueue: DispatchQueue = DispatchQueue.main) {
self.downloader = downloader
self.cache = cache
self.dispatchqueue = dispatchqueue
}
deinit {
task?.cancel()
}
func image(for url: URL?) -> UIImage {
guard let targetUrl = url else {
return UIImage.from(color: .gray)
}
guard let image = image else {
load(url: targetUrl)
return UIImage.from(color: .gray)
}
return image
}
private func load(url: URL) {
let key = url.absoluteString
if cache.isCached(forKey: key) {
cache.retrieveImage(forKey: key) { [weak self] (result) in
guard let self = self else { return }
switch result {
case .success(let value):
self.image = value.image
case .failure(let error):
print(error.localizedDescription)
}
}
} else {
downloader.downloadImage(with: url, options: nil, progressBlock: nil) { [weak self] (result) in
guard let self = self else { return }
switch result {
case .success(let value):
self.cache.storeToDisk(value.originalData, forKey: url.absoluteString)
self.image = value.image
case .failure(let error):
print(error.localizedDescription)
}
}
}
}
}
SwiftUI 3
Starting from iOS 15 we can now use AsyncImage:
AsyncImage(url: URL(string: "https://example.com/icon.png")) { image in
image.resizable()
} placeholder: {
ProgressView()
}
.frame(width: 50, height: 50)
SwiftUI 2
Here is a native SwiftUI solution that supports caching and multiple loading states:
import Combine
import SwiftUI
struct NetworkImage: View {
#StateObject private var viewModel = ViewModel()
let url: URL?
var body: some View {
Group {
if let data = viewModel.imageData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else if viewModel.isLoading {
ProgressView()
} else {
Image(systemName: "photo")
}
}
.onAppear {
viewModel.loadImage(from: url)
}
}
}
extension NetworkImage {
class ViewModel: ObservableObject {
#Published var imageData: Data?
#Published var isLoading = false
private static let cache = NSCache<NSURL, NSData>()
private var cancellables = Set<AnyCancellable>()
func loadImage(from url: URL?) {
isLoading = true
guard let url = url else {
isLoading = false
return
}
if let data = Self.cache.object(forKey: url as NSURL) {
imageData = data as Data
isLoading = false
return
}
URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.replaceError(with: nil)
.receive(on: DispatchQueue.main)
.sink { [weak self] in
if let data = $0 {
Self.cache.setObject(data as NSData, forKey: url as NSURL)
self?.imageData = data
}
self?.isLoading = false
}
.store(in: &cancellables)
}
}
}
(The above code doesn't use any third-party libraries, so it's easy to change the NetworkImage in any way.)
Demo
import Combine
import SwiftUI
struct ContentView: View {
#State private var showImage = false
var body: some View {
if showImage {
NetworkImage(url: URL(string: "https://stackoverflow.design/assets/img/logos/so/logo-stackoverflow.png"))
.frame(maxHeight: 150)
.padding()
} else {
Button("Load") {
showImage = true
}
}
}
}
(I used an exceptionally large Stack Overflow logo to show the loading state.)
Pass your Model to ImageRow struct which contains url.
import SwiftUI
import Combine
struct ContentView : View {
var listData: Post
var body: some View {
List(model.post) { post in
ImageRow(model: post) // Get image
}
}
}
/********************************************************************/
// Download Image
struct ImageRow: View {
let model: Post
var body: some View {
VStack(alignment: .center) {
ImageViewContainer(imageUrl: model.avatar_url)
}
}
}
struct ImageViewContainer: View {
#ObjectBinding var remoteImageURL: RemoteImageURL
init(imageUrl: String) {
remoteImageURL = RemoteImageURL(imageURL: imageUrl)
}
var body: some View {
Image(uiImage: UIImage(data: remoteImageURL.data) ?? UIImage())
.resizable()
.clipShape(Circle())
.overlay(Circle().stroke(Color.black, lineWidth: 3.0))
.frame(width: 70.0, height: 70.0)
}
}
class RemoteImageURL: BindableObject {
var didChange = PassthroughSubject<Data, Never>()
var data = Data() {
didSet {
didChange.send(data)
}
}
init(imageURL: String) {
guard let url = URL(string: imageURL) else { return }
URLSession.shared.dataTask(with: url) { (data, response, error) in
guard let data = data else { return }
DispatchQueue.main.async { self.data = data }
}.resume()
}
}
/********************************************************************/
A simpler and cleaner way to load an image in SwiftUI is to use the renowned Kingfisher library.
Add Kingfisher via Swift Package Manager
Select File > Swift Packages > Add Package Dependency. Enter
https://github.com/onevcat/Kingfisher.git
in the "Choose Package
Repository" dialog. In the next page, specify the version resolving
rule as "Up to Next Major" with "5.8.0" as its earliest version.
After
Xcode checking out the source and resolving the version, you can
choose the "KingfisherSwiftUI" library and add it to your app target.
import KingfisherSwiftUI
KFImage(myUrl)
Done! It's that easy
I would just use the onAppear callback
import Foundation
import SwiftUI
import Combine
import UIKit
struct ImagePreviewModel {
var urlString : String
var width : CGFloat = 100.0
var height : CGFloat = 100.0
}
struct ImagePreview: View {
let viewModel: ImagePreviewModel
#State var initialImage = UIImage()
var body: some View {
Image(uiImage: initialImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: self.width, height: self.height)
.onAppear {
guard let url = URL(string: self.viewModel.urlString) else { return }
URLSession.shared.dataTask(with: url) { (data, response, error) in
guard let data = data else { return }
guard let image = UIImage(data: data) else { return }
RunLoop.main.perform {
self.initialImage = image
}
}.resume()
}
}
var width: CGFloat { return max(viewModel.width, 100.0) }
var height: CGFloat { return max(viewModel.height, 100.0) }
}
Define the imageLoader as #ObjectBinding:
#ObjectBinding private var imageLoader: ImageLoader
It would make more sense to init the view with the url for the image :
struct SampleView : View {
var imageUrl: URL
private var image: UIImage {
imageLoader.image(for: imageUrl)
}
#ObjectBinding private var imageLoader: ImageLoader
init(url: URL) {
self.imageUrl = url
self.imageLoader = ImageLoader()
}
var body: some View {
Image(uiImage: image)
.frame(width: 200, height: 300)
.aspectRatio(contentMode: ContentMode.fit)
}
}
For example :
//Create a SampleView with an initial photo
var s = SampleView(url: URL(string: "https://placebear.com/200/300")!)
//You could then update the photo by changing the imageUrl
s.imageUrl = URL(string: "https://placebear.com/200/280")!
import SwiftUI
struct UrlImageView: View {
#ObservedObject var urlImageModel: UrlImageModel
init(urlString: String?) {
urlImageModel = UrlImageModel(urlString: urlString)
}
var body: some View {
Image(uiImage: urlImageModel.image ?? UrlImageView.defaultImage!)
.resizable()
.scaledToFill()
}
static var defaultImage = UIImage(systemName: "photo")
}
class UrlImageModel: ObservableObject {
#Published var image: UIImage?
var urlString: String?
init(urlString: String?) {
self.urlString = urlString
loadImage()
}
func loadImage() {
loadImageFromUrl()
}
func loadImageFromUrl() {
guard let urlString = urlString else {
return
}
let url = URL(string: urlString)!
let task = URLSession.shared.dataTask(with: url, completionHandler:
getImageFromResponse(data:response:error:))
task.resume()
}
func getImageFromResponse(data: Data?, response: URLResponse?, error: Error?)
{
guard error == nil else {
print("Error: \(error!)")
return
}
guard let data = data else {
print("No data found")
return
}
DispatchQueue.main.async {
guard let loadedImage = UIImage(data: data) else {
return
}
self.image = loadedImage
}
}
}
And using like this:
UrlImageView(urlString: "https://developer.apple.com/assets/elements/icons/swiftui/swiftui-96x96_2x.png").frame(width:100, height:100)
With the release of iOS 15 and macOS 12 in 2021, SwiftUI provides native AsyncImage view that enables loading images asynchronously. Bear in mind that you'll still have to fall back to a custom implementation for earlier OS versions.
AsyncImage(url: URL(string: "https://example.com/tile.png"))
The API itself also provides various ways to customise the image or provide a placeholder, for example:
AsyncImage(url: URL(string: "https://example.com/tile.png")) { image in
image.resizable(resizingMode: .tile)
} placeholder: {
Color.green
}
More in the Apple Developer Documentation.