1

The logic I'm trying to create for my logging in the app is:

  1. A ScrollView with a frame to control the height and allow the user to see logs from actions in the app, the logs should be scrollable to scroll up on previous appended logs.

  2. I've created a log view model which allows the log to be set and then appends to a log array and then get.

  3. The logs are set through actions in callbacks from various view controllers and actions from the user.

  4. currently I have the logs being retrieved in the UIViewControllerRepresentable - updateUIViewController method.

  5. The code works for each callback and for the user actions, the problems are: 5a. It's not scrollable to go to the top of the log messages, 5b. The log messages keep showing on the screen as updateUIViewController is continuously being called.

I was trying to think of a way to empty the array after each action, but not sure the best way to go about this.

Code:

LogViewModel:

import UIKit
import SwiftUI

class LogViewModel: ObservableObject{
        
    @Published var mTime: String = ""
    @Published var id: String = "#"
    @Published var mMessage: String = ""
    private var fullLogMessages: [String] = [""]
    
    func setTimeFormatter() -> DateFormatter {
        let formatter = DateFormatter()
           formatter.dateFormat = "HH:mm:ss"
           return formatter
    }
    
    func setTheTime(date: Date){
        self.mTime = setTimeFormatter().string(from: date)
    }
    
    func getTheTime() -> String{
        return self.mTime
    }
    
    func setTheMessage(mMessage: String) {
        ThreadUtil.runAsyncOnMainThread { [weak self] in
            self?.mMessage = mMessage
        }
    }
    
    func getTheMessage() -> String {
        return self.mMessage
    }
    
    func getTheFullLogMessage() -> [String] {
        let fullLog: String = getTheTime() + " - " + getTheGivenId() + " - " + getTheMessage()
        self.fullLogMessages.append(fullLog)
        return self.fullLogMessages
    }
    
    func setTheGivenId(id: String) {
        ThreadUtil.runAsyncOnMainThread { [weak self] in
            self?.id = id
        }
    }
    
    func getTheGivenId() -> String {
        return self.id
    }
}

Controllers: In each controller I've created a method like this to set the log messages:

 func setTheLogMessages(message: String) {
        self.logViewModel.setTheTime(date: date)
        self.logViewModel.setTheMessage(mMessage: message)
    }

In the view I have the UIViewControllerRepresentable:

struct MyScreenView_UI: UIViewControllerRepresentable{
    @ObservedObject var viewModel: myScreenViewModel
    @ObservedObject var logViewModel: LogViewModel
    @Binding var fullLogMessage: [String]
    
    func makeUIViewController(context: Context) -> some myViewController {
        print(#function)
        return myViewController(viewModel: viewModel, logViewModel: logViewModel)
    }
   
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        print(#function)
        DispatchQueue.main.asyncAfter(deadline: .now() +  0.5) {
            fullLogMessage = logViewModel.getTheFullLogMessage()
        }
    }
}

and the ScrollView for the UI:

    ScrollView{
        VStack(alignment: .leading, content: {
        Text("Logs")
            .font(.footnote).fontWeight(.medium)
        ForEach($fullLogMessage, id: \.self) { $logMessage in
            Text(logMessage)
                .font(.custom("FONT_NAME", size: 12))
                .disabled(true)
            }
          })
            .frame(width: 400, height: 50,  alignment: .leading)
    }
9
  • stackoverflow.com/questions/69942854/… Commented Jan 26, 2022 at 14:15
  • @loremipsum thanks, I asked the question you posted and it helped me previously. I was now implementing logging in the same way and it works, issue I have is once a log gets appended it doesn't stop updating as you can see I have it in the updateUIViewController. Need it to be more robust, scrollable and only have the single output of log messages at a time. Commented Jan 26, 2022 at 14:42
  • Don't do stuff in updateUIViewController unless you have checks so stuff doesn't get called unnecessarily. You can set it in makeUIViewController and then call it on purpose with the methods when you need it. Your code isn't reproducible so I can't give you more than that. I can't see the whole picture. Commented Jan 26, 2022 at 14:48
  • Look at this answer you will see a check in the update method in the second option. Commented Jan 26, 2022 at 14:50
  • Also consider another approach. I usually have a LogManager that everything and anything accesses the class whenever it wants. I have a Notification that gets posted when there is an error and a ViewModel at the top level subscribes to it and posts and Alert for me, sometimes I don't even know where the error comes from. You can easily maintain the log in the one class and make it visible. It is really seamless. Its is kind of an MVC setup because I use services to incorporate crashlytics and I have a system service the decides between Logger and osLog based on version Commented Jan 26, 2022 at 14:57

2 Answers 2

1

You haven't provided a Minimal Reproducible Example but here is a simplified version of what seems to be what you are trying to do.

First, add a LogManager that can be created by ANY class or struct

struct LogManager{
    var name: String
    ///Simplified post that takes in the String and uses the name as the source
    func postMessage(message: String){
        postMessage(message: .init(timestamp: Date(), message: message, source: name))
    }
    //MARK: Notification
    ///Sends a Notification with the provided message
    func postMessage(message: Message){
        NotificationCenter.default.post(name: .logManagerMessage, object: message)
    }
    ///Adds an observer to the manager's notification
    func observeMessageNotification(observer: Any, selector: Selector){
        NotificationCenter.default.addObserver(observer, selector: selector, name: .logManagerMessage, object: nil)
    }
}

Put at class or struct level the declaration for the manager

private let log = LogManager(name: "YourClassStructName")

Then call

log.postMessage(message: "your message here")

When you want to log a message.

In the ViewModel you would

  1. subscribe to the notifications
  2. maintain the array

Like below

class AppLogSampleViewModel: ObservableObject{
    static let shared: AppLogSampleViewModel = .init()
    private let manager = LogManager(name: "AppLogSampleViewModel")
    @Published var messages: [Message] = []
    private init(){
        //Observe the manager's notification
        manager.observeMessageNotification(observer: self, selector: #selector(postMessage(notification:)))
    }
    ///Puts the messages received into the array
    @objc
    func postMessage(notification: Notification){
        if notification.object is Message{
            messages.append(notification.object as! Message)
        }else{
            messages.append(.init(timestamp: Date(), message: "Notification received did not have message", source: "AppLogSampleViewModel :: \(#function)"))
        }
    }
} 

If your View won't be at the highest level you need to call.

let startListening: AppLogSampleViewModel = .shared

In the ContentView or AppDelegate so the ViewModel starts listening as quickly as possible. You won't necessarily use it for anything but it needs to be called as soon as you want it to start logging messages.

Only the View that shows the messages uses the instance of the ViewModel.

struct AppLogSampleView: View {
    @StateObject var vm: AppLogSampleViewModel = .shared
    //Create this variable anywhere in your app
    private let log = LogManager(name: "AppLogSampleView")
    var body: some View {
        List{
            Button("post", action: {
                //Post like this anywhere in your app
                log.postMessage(message: "random from button")
            })
            DisclosureGroup("log messages"){
                ForEach(vm.messages, content: { message in
                    VStack{
                        Text(message.timestamp.description)
                        Text(message.message)
                        Text(message.source)
                    }
                })
            }
        }
    }
}

Here is the rest of the code you need to get this sample working.

struct AppLogSampleView_Previews: PreviewProvider {
    static var previews: some View {
        AppLogSampleView()
    }
}
extension Notification.Name {
    static let logManagerMessage = Notification.Name("logManagerMessage")
}
struct Message: Identifiable{
    let id: UUID = .init()
    var timestamp: Date
    var message: String
    var source: String
}

Only the one View that shows the messages needs access to the ViewModel and only one ViewModel subscribes to the notifications.

All the other classes and structs will just create an instance of the manager and call the method to post the messages.

There is no sharing/passing between the classes and structs everybody gets their own instance.

Your manager can have as many or as little methods as you want, mine usually mimics the Logger from osLog with log, info, debug and error methods.

The specific methods call the osLog and 3rd party Analytics Services corresponding methods.

Also, the error method sends a notification that a ViewModel at the top level receives and shows an Alert.

To get all this detail working it takes a lot more code but you have the pattern with the code above.

In your code, in the updateUIViewController you break the single source if truth rule by copying the messages and putting them in another source of truth, right here.

fullLogMessage = logViewModel.getTheFullLogMessage()

This is also done without a check to make sure that you don't go into an infinite loop. Anytime there is code in an update method you should check that the work actually needs to be done. Such as comparing that the new location doesn't already match the old location.

Sign up to request clarification or add additional context in comments.

4 Comments

Sorry was away and just got back. Thank you for your answer, very appreciated. Few things, I've added the LogManager and I'm getting a few errors showing 1) .init 'no exact matches in call to initialiser' 2) Cannot find Message in scope 3) Type NSNotification.Name has no member .logManagerMessage. Also in the view where I have DisclosureGroup there's an error showing 'generic parameter Content could not be inferred with
Actually my bad think I missed the last bit. Just testing it now
Sorry for thr delay, been quite busy. It works. Only additional thing I would add in the view is the on Change to scroll to the last added log. Thank you
@Sam No problem kind of a waste that the bounty expired. you can vm.messages.reversed() in the loop or ScrollViewReader to implement the scrolling onChange
0

It seems like you made it very complicated. Let's do this with a simple approach.

1. Define what is a Log

  • Each log should be identifiable
  • Each log should represent it's creation date
  • Each log should have a message
struct Log: Equatable, Hashable {
    let id = UUID()
    let date = Date()
    let message: String
}

2. Define where logs should be

  • Changes in the logs should be observable
  • Logs should be accessible in any view
import Combine

class LogManager: ObservableObject {
    @Published var logs = [Log]()
}

Note: An EnvironmentObject is a good choice for such an object!


3. Define how to show logs

import SwiftUI

extension Log: Identifiable { }

struct ContentView: View {

    @EnvironmentObject private var logManager: LogManager

    var body: some View {
        List(logManager.logs) { log in
            HStack {
                Text(log.message)
                Text(log.date.ISO8601Format()) // Or any format you like
            }
        }
    }
}

This UI is just a simple sample and could be replaced by any other UI

Note: ScrollToTop is automatically enabled in the List

Note: You may want to use a singleton or injected logger because of the nature of the logger

Note: Don't forget to create and pass in the environment object

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.