Why does all formatting disappear from an NSTextView when using NSViewRepresentable and SwiftUI? - swift

I am making a small program using SwiftUI that allows users to create rich text "notes" in an NSTextView. I have enabled all of the formatting features from NSTextView, including the ability to work with images. The program is only for macOS and not for iOS/iPadOS.
The problem I am facing is that whenever the user types anything in the NSTextView, the caret moves to the end and all formatting and images disappear.
Since I am just using the standard formatting options provided by Apple, I have not subclassed NSTextStorage or anything like that. My use-case should be pretty simple.
The program is tiny so far and the entire source code is on GitHub (https://github.com/eiskalteschatten/ScratchPad), but I'll post the relevant code here.
This is my NSViewRepresentable class for the NSTextView:
import SwiftUI
struct RichTextEditor: NSViewRepresentable {
#EnvironmentObject var noteModel: NoteModel
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
guard let textView = scrollView.documentView as? NSTextView else {
return scrollView
}
textView.isRichText = true
textView.allowsUndo = true
textView.allowsImageEditing = true
textView.allowsDocumentBackgroundColorChange = true
textView.allowsCharacterPickerTouchBarItem = true
textView.isAutomaticLinkDetectionEnabled = true
textView.displaysLinkToolTips = true
textView.isAutomaticDataDetectionEnabled = true
textView.isAutomaticTextReplacementEnabled = true
textView.isAutomaticDashSubstitutionEnabled = true
textView.isAutomaticSpellingCorrectionEnabled = true
textView.isAutomaticQuoteSubstitutionEnabled = true
textView.isAutomaticTextCompletionEnabled = true
textView.isContinuousSpellCheckingEnabled = true
textView.usesAdaptiveColorMappingForDarkAppearance = true
textView.usesInspectorBar = true
textView.usesRuler = true
textView.usesFindBar = true
textView.usesFontPanel = true
textView.importsGraphics = true
textView.delegate = context.coordinator
context.coordinator.textView = textView
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
context.coordinator.textView?.textStorage?.setAttributedString(noteModel.noteContents)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTextViewDelegate {
var parent: RichTextEditor
var textView : NSTextView?
init(_ parent: RichTextEditor) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let _textView = notification.object as? NSTextView else {
return
}
self.parent.noteModel.noteContents = _textView.attributedString()
}
}
}
On GitHub: https://github.com/eiskalteschatten/ScratchPad/blob/main/ScratchPad/Notes/RichTextEditor.swift
And this is my NoteModel class responsible for managing the NSTextView content:
import SwiftUI
import Combine
final class NoteModel: ObservableObject {
private var switchingPages = false
#Published var pageNumber = UserDefaults.standard.value(forKey: "pageNumber") as? Int ?? 1 {
didSet {
UserDefaults.standard.set(pageNumber, forKey: "pageNumber")
switchingPages = true
noteContents = NSAttributedString(string: "")
openNote()
switchingPages = false
}
}
#Published var noteContents = NSAttributedString(string: "") {
didSet {
if !switchingPages {
saveNote()
}
}
}
private var noteName: String {
return "\(NoteManager.NOTE_NAME_PREFIX)\(pageNumber).rtfd"
}
init() {
openNote()
}
private func openNote() {
// This is necessary, but macOS seems to recover the stale bookmark automatically, so don't handle it for now
var isStale = false
guard let bookmarkData = UserDefaults.standard.object(forKey: "storageLocationBookmarkData") as? Data,
let storageLocation = try? URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
else {
ErrorHandling.showErrorToUser("No storage location for your notes could be found!", informativeText: "Please try re-selecting your storage location in the settings.")
return
}
let fullURL = storageLocation.appendingPathComponent(noteName)
let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.rtfd]
do {
guard storageLocation.startAccessingSecurityScopedResource() else {
ErrorHandling.showErrorToUser("ScratchPad is not allowed to access the storage location for your notes!", informativeText: "Please try re-selecting your storage location in the settings.")
return
}
if let _ = try? fullURL.checkResourceIsReachable() {
let attributedString = try NSAttributedString(url: fullURL, options: options, documentAttributes: nil)
noteContents = attributedString
}
fullURL.stopAccessingSecurityScopedResource()
} catch {
print(error)
ErrorHandling.showErrorToUser(error.localizedDescription)
}
}
private func saveNote() {
// This is necessary, but macOS seems to recover the stale bookmark automatically, so don't handle it for now
var isStale = false
guard let bookmarkData = UserDefaults.standard.object(forKey: "storageLocationBookmarkData") as? Data,
let storageLocation = try? URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
else {
ErrorHandling.showErrorToUser("No storage location for your notes could be found!", informativeText: "Please try re-selecting your storage location in the settings.")
return
}
let fullURL = storageLocation.appendingPathComponent(noteName)
do {
guard storageLocation.startAccessingSecurityScopedResource() else {
ErrorHandling.showErrorToUser("ScratchPad is not allowed to access the storage location for your notes!", informativeText: "Please try re-selecting your storage location in the settings.")
return
}
let rtdf = noteContents.rtfdFileWrapper(from: .init(location: 0, length: noteContents.length))
try rtdf?.write(to: fullURL, options: .atomic, originalContentsURL: nil)
fullURL.stopAccessingSecurityScopedResource()
} catch {
print(error)
ErrorHandling.showErrorToUser(error.localizedDescription)
}
}
}
On GitHub: https://github.com/eiskalteschatten/ScratchPad/blob/main/ScratchPad/Notes/NoteModel.swift
Does anyone have any idea why this is happening and/or how to fix it?
I have found these similar issues, but they don't really help me much:
Replacing NSAttributedString in NSTextStorage Moves NSTextView Cursor - I don't have any custom syntax highlighting or anything like that.
Cursor always jumps to the end of the UIViewRepresentable TextView when a newline is started before the final line + after last character on the line - Only solves the caret issue and causes jerky scroll behavior in longer documents.
Edit: I forgot to mention that I'm using macOS Ventura, but am targeting 12.0 or higher.
Edit #2: I have significantly updated the question to reflect what I've found through more debugging.

Related

Swift change language in runtime without restarting the app

The code I wrote changes the language only if I restart the app, but I would like it to change language immediately.
It's possible to do it?
The language switch works fine, but only after I restart the app.
Maybe the problem is in the extension string?
Localizable.strings (it)
"label.language" = "Lingua";
Localizable.strings (en)
"label.language" = "Language";
struct ConstantFile {
static let labelLanguage = NSLocalizedString("label.language".localized, comment: "")
}
extension String {
var localized: String {
if let _ = UserDefaults.standard.string(forKey: "UserDefaultLanguage") {} else {
// setting value default
UserDefaults.standard.set(Language.italian.rawValue, forKey: "UserDefaultLanguage")
UserDefaults.standard.synchronize()
}
let lang = UserDefaults.standard.string(forKey: "UserDefaultLanguage")
let path = Bundle.main.path(forResource: lang, ofType: "lproj")
let bundle = Bundle(path: path!)
return NSLocalizedString(self, tableName: nil, bundle: bundle!, value: "", comment: "")
}
}
class ViewController: UIViewController {
#IBOutlet weak var labelTitleLanguage: UILabel!
let userDefaults = UserDefaults.standard
let LANGUAGE_KEY = "UserDefaultLanguage"
override func viewDidLoad() {
super.viewDidLoad()
labelTitleLanguage.text = ConstantFile.labelLanguage
}
#objc func tapBtnConfirmation(_ sender: UIButton) {
let list = list[self.pickerView.selectedRow(inComponent: 0)]
buttonPickerViewLanguage.setTitle(list, for: .normal)
if list == ConstantFile.labelLanguageItalian {
userDefaults.set(Language.italian.rawValue, forKey: LANGUAGE_KEY)
} else if list == ConstantFile.labelLanguageEnglish {
userDefaults.set(Language.english.rawValue, forKey: LANGUAGE_KEY)
}
}
}
You'll need to have a notification sent when changing language.
Then in your views, you observe this notification, and when it's raised, you update all translated texts in the view.
That's what I do

How to change hyper link text color in SwiftUI

I am trying to custom change the default font color of hyperlink in a given markdown string with SwiftUI. Something equivalent to txtString.linkTextAttributes = [ .foregroundColor: UIColor.red ] of UIKit.
Here's my code:
import SwiftUI
struct TextViewAttributedString: View {
var markdownString: String
var body: some View {
Text(convertIntoAttributedString(markdownString:markdownString))
}
private func convertIntoAttributedString(markdownString: String) -> AttributedString {
guard var attributedString = try? AttributedString(
markdown: markdownString,
options: AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true,
interpretedSyntax: .inlineOnlyPreservingWhitespace))
else {
return AttributedString(markdownString)
}
attributedString.font = .custom("Times New Roman", size: 16, relativeTo: .body)
let runs = attributedString.runs
for run in runs {
let range = run.range
if let textStyle = run .inlinePresentationIntent {
if textStyle.contains(.stronglyEmphasized) { // .stronglyEmphasized is available
// change foreground color of bold text
attributedString[range].foregroundColor = .green
}
if textStyle.contains(.linkTextAttributes) { // compiler error since .linkTextAttributes not available
// change color here but .linkTextAttributes is not available in inlinePresentationIntent
// Any other way to change the hyperlink color?
}
}
}
return attributedString
}
}
Example View where AttribtedString being used
import SwiftUI
struct AttributedStringView: View {
let text: String = "**Bold** regular and _italic_ \nnewline\n[hyperlink](www.google.com)"
var body: some View {
TextViewAttributedString(markdownString: text)
}
}
struct AttributedStringView_Previews: PreviewProvider {
static var previews: some View {
AttributedStringView()
}
}
Result:
Result Screen
Reference Docs: https://developer.apple.com/documentation/foundation/attributedstring
https://developer.apple.com/videos/play/wwdc2021/10109/
if run.link != nil {
// change foreground color of link
attributedString[range].foregroundColor = .orange
}
iOS 15:
You can initialize SwiftUIs Text with an AttributedString. You can create the AttributedString from a markdown string, find the link labels with regex and change the color for that part of the AttributedString.
let markdown = "**Bold** regular and _italic_ [hyperlink cool](www.google.com)"
let linkColor = UIColor.green
var body: some View {
Text(makeAttributedString())
}
func makeAttributedString() -> AttributedString {
var string = (try? AttributedString(markdown: markdown)) ?? AttributedString("Error markdown")
let linkLabels = getLinkLabels(markdownString: markdown)
for label in linkLabels {
if let range = string.range(of: label) {
string[range].foregroundColor = linkColor
}
}
return string
}
func getLinkLabels(markdownString: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: "\\[[a-zA-Z0-9_ ]*\\]") else { return [] }
let results = regex.matches(in: markdownString,
range: NSRange(markdownString.startIndex..., in: markdownString))
let labels = results.compactMap {
Range($0.range, in: markdownString).map { String(markdownString[$0]) }
}
// removing the brackets from the link labels before return
return labels.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) }
}
I had to replace the AttributeContainer.
if run.link != nil {
var container = AttributeContainer()
container.foregroundColor = .red
attributedString[range].setAttributes(container)
}
The easiest solution for me was:
Text(.init("some text **[google.com](https://google.com)**"))
.accentColor(.red)

macOS menu bar text with icon

As you can see in the image I would like to be able to do a similar one, to make a way that instead of showing only the icon of the sun, also showing a text.
As seen in the image below, an icon followed by a text.
But I only managed to do this:
The problem I would like to put the icon on the left or right of the text, not above it, can you give me a hand?
P.s.
The text must change accordingly, how can I make the StatusBarController receive the text changes.
import AppKit
import SwiftUI
class StatusBarController {
#ObservedObject var userPreferences = UserPreferences.instance
private var statusBar: NSStatusBar
private var statusItem: NSStatusItem
private var popover: NSPopover
init(_ popover: NSPopover) {
self.popover = popover
statusBar = NSStatusBar.init()
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let statusBarButton = statusItem.button {
if let _ = userPreferences.$inDownload {
statusItem.button?.title = userPreferences.$percentualDownload
}
statusBarButton.image = #imageLiteral(resourceName: "Weather")
statusBarButton.image?.size = NSSize(width: 18.0, height: 18.0)
statusBarButton.image?.isTemplate = true
statusBarButton.action = #selector(togglePopover(sender:))
statusBarButton.target = self
statusBarButton.imagePosition = NSControl.ImagePosition.imageLeft
}
}
#objc func togglePopover(sender: AnyObject) {
if(popover.isShown) {
hidePopover(sender)
}
else {
showPopover(sender)
}
}
func showPopover(_ sender: AnyObject) {
if let statusBarButton = statusItem.button {
popover.show(relativeTo: statusBarButton.bounds, of: statusBarButton, preferredEdge: NSRectEdge.maxY)
}
}
func hidePopover(_ sender: AnyObject) {
popover.performClose(sender)
}
}
I'm thinking of using something like that:
import EventKit
import ServiceManagement
private struct PreferencesKeys {
static let backgroundIsTransparent = "backgroundIsTransparent"
static let inDownload = "inDownload"
static let percentualDownload = "percentualDownload"
}
class UserPreferences: ObservableObject {
static let instance = UserPreferences()
private init() {
// This prevents others from using the default '()' initializer for this class.
}
private static let defaults = UserDefaults.standard
#Published var backgroundIsTransparent: Bool = {
guard UserDefaults.standard.object(forKey: PreferencesKeys.backgroundIsTransparent) != nil else {
return true
}
return UserDefaults.standard.bool(forKey: PreferencesKeys.backgroundIsTransparent)
}() {
didSet {
UserPreferences.defaults.set(backgroundIsTransparent, forKey: PreferencesKeys.backgroundIsTransparent)
}
}
#Published var inDownload: Bool = {
guard UserDefaults.standard.object(forKey: PreferencesKeys.inDownload) != nil else {
return true
}
return UserDefaults.standard.bool(forKey: PreferencesKeys.inDownload)
}() {
didSet {
UserPreferences.defaults.set(inDownload, forKey: PreferencesKeys.inDownload)
}
}
#Published var percentualDownload: String = {
guard UserDefaults.standard.object(forKey: PreferencesKeys.percentualDownload) != nil else {
return "0%"
}
return UserDefaults.standard.string(forKey: PreferencesKeys.percentualDownload)!
}() {
didSet {
UserPreferences.defaults.set(percentualDownload, forKey: PreferencesKeys.percentualDownload)
}
}
}
but I get the following error:
Edit:
First problem solved I used:
statusBarButton.imagePosition = NSControl.ImagePosition.imageLeft
statusBarButton.imagePosition = NSControl.ImagePosition.imageRight
For the update text problem, what can I do?

Firestore pagination using MVVM architecture swift

I don't quite understand what I am doing wrong since I am very new to MVVM. It worked in MVC architecture. I've setup my VM and am able to get the first set of results and even then that's not working properly. I get 4 results instead of 10 which is what LOADLIMIT is set as. I was able to get it to work in an MVC architecture without any issues. The VM function which triggers the query is called multiple (3) times instead of just once i.e. even prior to scrolling.
Here is my VM:
enum FetchRestaurant {
case success
case error
case location
case end
}
class ListViewModel {
let restaurant: [Restaurant]?
let db = Firestore.firestore()
var restaurantArray = [Restaurant]()
var lastDocument: DocumentSnapshot?
var currentLocation: CLLocation?
typealias fetchRestaurantCallback = (_ restaurants: [Restaurant]?, _ message: String?, _ status: FetchRestaurant) -> Void
var restaurantFetched: fetchRestaurantCallback?
var fetchRestaurant: FetchRestaurant?
init(restaurant: [Restaurant]) {
self.restaurant = restaurant
}
func fetchRestaurantCallback (callback: #escaping fetchRestaurantCallback) {
self.restaurantFetched = callback
}
func fetchRestaurants(address: String) {
print("address received: \(address)")
getLocation(from: address) { location in
if let location = location {
self.currentLocation = location
self.queryGenerator(at: location)
} else {
self.restaurantFetched?(nil, nil, .location)
}
}
}
func queryGenerator(at location: CLLocation) {
var query: Query!
if restaurantArray.isEmpty {
query = db.collection("Restaurant_Data").whereField("distributionType", isLessThanOrEqualTo: 2).limit(to: Constants.Mealplan.LOADLIMIT)
} else {
print("last document:\(String(describing: lastDocument?.documentID))")
query = db.collection("Restaurant_Data").whereField("distributionType", isLessThanOrEqualTo: 2).start(afterDocument: lastDocument!).limit(to: Constants.Mealplan.LOADLIMIT)
}
batchFetch(query: query)
}
func batchFetch(query: Query) {
query.getDocuments { (querySnapshot, error) in
if let error = error {
self.restaurantFetched?(nil, error.localizedDescription, .error)
} else if querySnapshot!.isEmpty {
self.restaurantFetched?(nil, nil, .end)
} else if !querySnapshot!.isEmpty {
let queriedRestaurants = querySnapshot?.documents.compactMap { querySnapshot -> Restaurant? in
return try? querySnapshot.data(as: Restaurant.self)
}
guard let restaurants = queriedRestaurants,
let currentLocation = self.currentLocation else {
self.restaurantFetched?(nil, nil, .end)
return }
self.restaurantArray.append(contentsOf: self.applicableRestaurants(allQueriedRestaurants: restaurants, location: currentLocation))
DispatchQueue.main.asyncAfter(deadline: .now(), execute: {
self.restaurantFetched?(self.restaurantArray, nil, .success)
})
self.lastDocument = querySnapshot!.documents.last
}
}
}
func getLocation(from address: String, completionHandler: #escaping (_ location: CLLocation?) -> Void) {
let geocoder = CLGeocoder()
geocoder.geocodeAddressString(address) { (placemarks, error) in
guard let placemarks = placemarks,
let location = placemarks.first?.location else {
completionHandler(nil)
return
}
completionHandler(location)
}
}
}
And in the VC viewDidLoad:
var fetchMore = false
var reachedEnd = false
let leadingScreensForBatching: CGFloat = 5.0
var searchController = UISearchController(searchResultsController: nil)
var currentAddress : String?
var listViewModel = ListViewModel(restaurant: [Restaurant]())
override func viewDidLoad() {
super.viewDidLoad()
listViewModel.fetchRestaurantCallback { (restaurants, error, result) in
switch result {
case .success :
self.loadingShimmer.stopShimmering()
self.loadingShimmer.removeFromSuperview()
guard let fetchedRestaurants = restaurants else { return }
self.restaurantArray.append(contentsOf: fetchedRestaurants)
self.tableView.reloadData()
self.fetchMore = false
case .location :
self.showAlert(alertTitle: "No businesses nearby", message: "Try going back and changing the address")
case .error :
guard let error = error else { return }
self.showAlert(alertTitle: "Error", message: error)
case .end :
self.fetchMore = false
self.reachedEnd = true
}
}
if let currentAddress = currentAddress {
listViewModel.fetchRestaurants(address: currentAddress)
}
}
I would really appreciate links or resources for implementing MVVM in Swift for a Firestore back-end. I'm coming up short on searches here and on Google. Even tried medium.
EDIT
class ListViewController: UITableViewController {
lazy var loadingShimmer: UIImageView = {
let image = UIImage(named: "shimmer_background")
let imageview = UIImageView(image: image)
imageview.contentMode = .top
imageview.translatesAutoresizingMaskIntoConstraints = false
return imageview
}()
var restaurantArray = [Restaurant]()
var planDictionary = [String: Any]()
var fetchMore = false
var reachedEnd = false
let leadingScreensForBatching: CGFloat = 5.0
var searchController = UISearchController(searchResultsController: nil)
var currentAddress : String?
var listViewModel = ListViewModel(restaurant: [Restaurant]())
override func viewDidLoad() {
super.viewDidLoad()
setupTable()
}
override func viewWillAppear(_ animated: Bool) {
clearsSelectionOnViewWillAppear = false
}
func setupTable() {
navigationItem.backBarButtonItem = UIBarButtonItem(title: "Restaurant", style: .plain, target: nil, action: nil)
tableView.register(RestaurantCell.self, forCellReuseIdentifier: "Cell")
tableView.delegate = self
tableView.dataSource = self
let navigationBarHeight: CGFloat = self.navigationController!.navigationBar.frame.height
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: -navigationBarHeight, right: 0)
tableView.separatorStyle = .none
tableView.showsVerticalScrollIndicator = false
tableView.addSubview(loadingShimmer)
loadingShimmer.topAnchor.constraint(equalTo: tableView.safeAreaLayoutGuide.topAnchor).isActive = true
loadingShimmer.leadingAnchor.constraint(equalTo: tableView.leadingAnchor).isActive = true
loadingShimmer.trailingAnchor.constraint(equalTo: tableView.trailingAnchor).isActive = true
loadingShimmer.startShimmering()
initialSetup()
}
func initialSetup() {
let addressOne = planDictionary["addressOne"] as! String + ", "
let city = planDictionary["city"] as! String + ", "
let postalCode = planDictionary["postalCode"] as! String
currentAddress = addressOne + city + postalCode
setupSearch()
listViewModel.fetchRestaurantCallback { (restaurants, error, result) in
switch result {
case .success :
self.loadingShimmer.stopShimmering()
self.loadingShimmer.removeFromSuperview()
guard let fetchedRestaurants = restaurants else { return }
self.restaurantArray.append(contentsOf: fetchedRestaurants)
self.tableView.reloadData()
self.fetchMore = false
case .location :
self.showAlert(alertTitle: "No businesses nearby", message: "Try going back and changing the address")
case .error :
guard let error = error else { return }
self.showAlert(alertTitle: "Error", message: error)
case .end :
self.fetchMore = false
self.reachedEnd = true
}
}
if let currentAddress = currentAddress {
listViewModel.fetchRestaurants(address: currentAddress)
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let off = scrollView.contentOffset.y
let off1 = scrollView.contentSize.height
if off > off1 - scrollView.frame.height * leadingScreensForBatching {
print("\(fetchMore), \(reachedEnd)")
if !fetchMore && !reachedEnd {
if let address = self.currentAddress {
print("address sent: \(address)")
listViewModel.fetchRestaurants(address: address)
}
}
}
}
}
That you're only getting back 4 results instead of 10 is not due to a faulty query or get-document request—those are coded properly. You're either losing documents when you parse them (some are failing Restaurant initialization), Constants.Mealplan.LOADLIMIT is wrong, or there aren't more than 4 documents in the collection itself that satisfy the query.
That the query is executed 3 times instead of once is also not due to anything in this code—viewDidLoad is only called once and geocodeAddressString only returns once. You're making a fetch request elsewhere that we can't see.
In the batchFetch method, you have a guard that returns out of the function without ever calling its completion handler. This will leave the UI in a state of limbo. I'd recommend always calling the completion handler no matter why the function returns.
You never manage the document cursor. If the get-document return has less documents than the load limit, then nil the last-document cursor. This way, when you attempt to get the next page of documents, guard against a nil cursor and see if there is even more to fetch.
There's no need to pass in an empty array and have your function fill it; simply construct and return an array of results within ListViewModel itself.
We can't see how you trigger pagination. Is it through a scroll delegate when the user reaches the bottom or through a button tap, for example? If it's through a scroll delegate, then I'd disable that for now and see how many returns you get—I suspect one, instead of 3.
What is the particular reason you've ditched MVC for MVVM here? With MVC, you can get pagination up with just a few lines of code. I think MVVM is overkill for iOS applications and would advise against using it unless you have a compelling reason.

I can print data but can't assign it to a label in Swift

I sent my data from my API call to my InfoController viewDidLoad. There, I was able to safely store it in a skillName constant, and also printed it, receiving all the information by console.
The problem comes when I try to assign this variable to my skillLabel.
override func viewDidLoad() {
super.viewDidLoad()
configureViewComponents()
fetchPokemons { (names) in
guard var skillName = names as? String else { return }
self.pokemon?.skillName = skillName
self.allNames = skillName
print(self.allNames)
}
}
There, when I print allNames, the console shows all the data I need. This is how the data looks like: Data Example
And the computed property where I wanna use this data looks is:
var pokemon: Pokemon? {
didSet {
guard let id = pokemon?.id else { return }
guard let data = pokemon?.image else { return }
navigationItem.title = pokemon?.name?.capitalized
infoLabel.text = pokemon?.description
infoView.pokemon = pokemon
if id == pokemon?.id {
imageView.image = UIImage(data: data)
infoView.configureLabel(label: infoView.skillLabel, title: "Skills", details: "\(allNames)")
}
}
}
PD: allNames is a String variable I have at InfoController class-level.
This is how my app looks when run:
PokeApp
My goal is to get that details param to show the skillName data, but it returns nil, idk why. Any advice?
EDIT1: My func that fetches the Pokemon data from my service class is this one:
func fetchPokemons(handler: #escaping (String) -> Void) {
controller.service.fetchPokes { (poke) in
DispatchQueue.main.async {
self.pokemon? = poke
guard let skills = poke.abilities else { return }
for skill in skills {
guard let ability = skill.ability else { return }
guard var names = ability.name!.capitalized as? String else { return }
self.pokemon?.skillName = names
handler(names)
}
}
}
}
EDIT2: InfoView class looks like:
class InfoView: UIView {
// MARK: - Properties
var delegate: InfoViewDelegate?
// This whole block assigns the attributes that will be shown at the InfoView pop-up
// It makes the positioning of every element possible
var pokemon: Pokemon? {
didSet {
guard let pokemon = self.pokemon else { return }
guard let type = pokemon.type else { return }
guard let defense = pokemon.defense else { return }
guard let attack = pokemon.attack else { return }
guard let id = pokemon.id else { return }
guard let height = pokemon.height else { return }
guard let weight = pokemon.weight else { return }
guard let data = pokemon.image else { return }
if id == pokemon.id {
imageView.image = UIImage(data: data)
}
nameLabel.text = pokemon.name?.capitalized
configureLabel(label: typeLabel, title: "Type", details: type)
configureLabel(label: pokedexIdLabel, title: "Pokedex Id", details: "\(id)")
configureLabel(label: heightLabel, title: "Height", details: "\(height)")
configureLabel(label: defenseLabel, title: "Defense", details: "\(defense)")
configureLabel(label: weightLabel, title: "Weight", details: "\(weight)")
configureLabel(label: attackLabel, title: "Base Attack", details: "\(attack)")
}
}
let skillLabel: UILabel = {
let label = UILabel()
return label
}()
let imageView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
return iv
}()
. . .
}
infoView.configureLabel is this:
func configureLabel(label: UILabel, title: String, details: String) {
let attributedText = NSMutableAttributedString(attributedString: NSAttributedString(string: "\(title): ", attributes: [NSAttributedString.Key.font : UIFont.boldSystemFont(ofSize: 16), NSAttributedString.Key.foregroundColor: Colors.softRed!]))
attributedText.append(NSAttributedString(string: "\(details)", attributes: [NSAttributedString.Key.font : UIFont.systemFont(ofSize: 16), NSAttributedString.Key.foregroundColor: UIColor.gray]))
label.attributedText = attributedText
}
EDIT 3: Structures design
struct Pokemon: Codable {
var results: [Species]?
var abilities: [Ability]?
var id, attack, defense: Int?
var name, type: String?
...
}
struct Ability: Codable {
let ability: Species?
}
struct Species: Codable {
let name: String?
let url: String?
}
Jump to the Edit2 paragraph for the final answer!
Initial Answer:
I looks like you UI does not get updated after the controller fetches all the data.
Since all of you UI configuration code is inside the var pokemon / didSet, it's a good idea to extract it to a separate method.
private func updateView(with pokemon: Pokemon?, details: String?) {
guard let id = pokemon?.id, let data = pokemon?.image else { return }
navigationItem.title = pokemon?.name?.capitalized
infoLabel.text = pokemon?.description
infoView.pokemon = pokemon
if id == pokemon?.id {
imageView.image = UIImage(data: data)
infoView.configureLabel(label: infoView.skillLabel, title: "Skills", details: details ?? "")
}
}
and now you can easily call in the the didSet
var pokemon: Pokemon? {
didSet { updateView(with: pokemon, details: allNames) }
}
and fetchPokemons completion aswell
override func viewDidLoad() {
super.viewDidLoad()
configureViewComponents()
fetchPokemons { (names) in
guard var skillName = names as? String else { return }
self.pokemon?.skillName = skillName
self.allNames = skillName
print(self.allNames)
DispatchQueue.main.async {
self.updateView(with: self.pokemon, details: self.allNames)
}
}
}
It's super important to do any UI setup on the main queue.
Edit:
The fetch function may be causing the problems! you are calling handler multiple times:
func fetchPokemons(handler: #escaping (String) -> Void) {
controller.service.fetchPokes { (poke) in
DispatchQueue.main.async {
self.pokemon? = poke
guard let skills = poke.abilities else { return }
let names = skills.compactMap { $0.ability?.name?.capitalized }.joined(separator: ", ")
handler(names)
}
}
}
Edit2:
After looking at your codebase there are a couple of things you need to change:
1. fetchPokemons implementation
the handler of controller.service.fetchPokes gets called for every pokemon so we need to check if the fetched one is the current (self.pokemon) and then call the handler with properly formated skills.
func fetchPokemons(handler: #escaping (String) -> Void) {
controller.service.fetchPokes { (poke) in
guard poke.id == self.pokemon?.id else { return }
self.pokemon? = poke
let names = poke.abilities?.compactMap { $0.ability?.name?.capitalized }.joined(separator: ", ")
handler(names ?? "-")
}
}
2. update viewDidLoad()
now simply pass the names value to the label.
override func viewDidLoad() {
super.viewDidLoad()
configureViewComponents()
fetchPokemons { (names) in
self.pokemon?.skillName = names
self.infoView.configureLabel(label: self.infoView.skillLabel, title: "Skills", details: names)
}
}
3. Refactor var pokemon: Pokemon? didSet observer
var pokemon: Pokemon? {
didSet {
guard let pokemon = pokemon, let data = pokemon.image else { return }
navigationItem.title = pokemon.name?.capitalized
infoLabel.text = pokemon.description!
infoView.pokemon = pokemon
imageView.image = UIImage(data: data)
}
}