skip to Main Content

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

2

Answers


  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.

    Login or Signup to reply.
  2. 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

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search