Is it possible to UI Test individual Swift UI components? - ios

I am super new to Swift and SwiftUI and I have started a new project using SwiftUI. I have some experience in other component based libraries for the web and I wanted a way to use the same pattern for iOS development.
Is there a way to ui test individual components in SwiftUI? For example, I have created a Map component that accepts coordinates and renders a map and I want to test this map individually by making the app immediately render the component. Here is my code and test code at the moment:
// App.swift (main)
// Map is not rendered yet
#main
struct PicksApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
// MyMap.swift
struct MyMap: View {
#State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: 25.7617,
longitude: 80.1918
),
span: MKCoordinateSpan(
latitudeDelta: 10,
longitudeDelta: 10
)
)
var body: some View {
Map(coordinateRegion: $region)
}
}
struct MyMap_Previews: PreviewProvider {
static var previews: some View {
MyMap()
}
}
// MyMapUITests.swift
class MyMapUITests: XCTestCase {
func testMapExists() throws {
let app = XCUIApplication()
app.launch()
let map = app.maps.element
XCTAssert(map.exists, "Map does not exist")
}
}
Is it possible to tell UI Test framework to only test one component instead of launching the entire app and making me navigate between each view before I am able to get to my view?
For example, in my case, there is going to be a login view when the app opens for the first time (which is every time from perspective of ui testing) and the map view can be located inside the app somewhere. I want to be able to test only the map view without testing end-to-end user experience.

One approach you could take is to transform your app into a catalog one if some environment variables are found. For this you'll have to keep a fixed collection of views to use as the root of the app:
#main
struct PicksApp: App {
static let viewBuilders: [String: () -> AnyView] = [
"ContentView": { AnyView(ContentView()) },
"MyMap": { AnyView(MyMap()) }]
var body: some Scene {
WindowGroup {
if let viewName = ProcessInfo().customUITestedView,
let viewBuilder = Self.viewBuilders[viewName] {
viewBuilder()
} else {
AnyView(ContentView())
}
}
}
}
Here's the ProcessInfo helper method:
extension ProcessInfo {
var customUITestedView: String? {
guard environment["MyUITestsCustomView"] == "true" else { return nil }
return environment["MyCustomViewName"]
}
}
With the above changes, the UI test needs only two more lines of code - the enviroment preparation:
func testMapExists() throws {
let app = XCUIApplication()
app.launchEnvironment["MyUITestsCustomView"] = "true"
app.launchEnvironment["MyCustomViewName"] = "MyMap"
app.launch()
let map = app.maps.element
XCTAssert(map.exists, "Map does not exist")
}

Related

Using iOS 15+ API (#AccessibilityFocusState) without dropping support for earlier iOS versions

Apple introduced the #FocusState and #AccessibilityFocusState and their respective APIs for iOS 15. Typically when I have an app that supports multiple versions and I need to use a new API, I would wrap the code with if #available (iOS x) {} or use #available.
For managing focus state, I need to declare a var with the #AccessibilityFocusState property wrapper, and literally including the following code in a SwiftUI View will cause it to crash at runtime on an iOS 14 device, although the compiler has no complaints:
#available(iOS 15.0, tvOS 15.0, *)
#AccessibilityFocusState var focus: FocusLocation?
On tvOS, I can use the compiler directive #if os(tvOS) … #endif to this compile conditionally, but this isn't an option for iOS versions which are handled at runtime.
To be clear, I know that I can’t use this API for iOS 14 devices, but dropping support for iOS 14 is another issue entirely
Is there anyway to use this iOS 15+ API for iOS 15+ VoiceOver users, and still allow general iOS 14 users to run the rest of the app?
It turns out there is a good way to handle this: put #AccessibilityFocusState in a custom modifier.
#available(iOS 15, *)
struct FocusModifier: ViewModifier {
#AccessibilityFocusState var focusTarget: AccessibilityFocusTarget?
#Environment(\.lastAccessibilityFocus) #Binding var lastFocus
// this is the value passed into the modifier that associates an enum value with this particular view
var focusTargetValue: AccessibilityFocusTarget?
init(targetValue: AccessibilityFocusTarget) {
focusTargetValue = targetValue
}
func body(content: Content) -> some View {
content.accessibilityFocused($focusTarget, equals: focusTargetValue)
.onChange(of: focusTarget) { focus in
if focus == focusTargetValue {
lastFocus = focusTargetValue
}
}.onReceive(NotificationCenter.default.publisher(for: .accessibilityFocusAssign)) { notification in
if let userInfo = notification.userInfo,
let target = userInfo[UIAccessibility.assignAccessibilityFocusUserInfoKey] as? AccessibilityFocusTarget,
target == focusTargetValue
{
focusTarget = target
}
}
}
}
public extension View {
// Without #ViewBuilder, it will insist on inferring a single View type
#ViewBuilder
func a11yFocus(targetValue: AccessibilityFocusTarget) -> some View {
if #available(iOS 15, *) {
modifier(FocusModifier(targetValue: targetValue))
} else {
self
}
}
}
where AccessibilityFocusTarget is just an enum of programmatic focus candidates:
public enum AccessibilityFocusTarget: String, Equatable {
case title
case shareButton
case favouriteButton
}
And I'm storing the last focused element as a Binding to AccessibilityFocusTarget in the environment:
public extension EnvironmentValues {
private struct LastAccessibilityFocus: EnvironmentKey {
static let defaultValue: Binding<AccessibilityFocusTarget?> = .constant(nil)
}
var lastAccessibilityFocus: Binding<AccessibilityFocusTarget?> {
get { self[LastAccessibilityFocus.self] }
set { self[LastAccessibilityFocus.self] = newValue
}
}
}
The .onReceive block lets us will take a notification with the AccessibilityFocusTarget value in userInfo and programmatically set focus to the View associated with that value via the modifier.
I've added a custom notification and userInfo key string:
extension Notification.Name {
public static let accessibilityFocusAssign = Notification.Name("accessibilityFocusAssignNotification")
}
extension UIAccessibility {
public static let assignAccessibilityFocusUserInfoKey = "assignAccessibilityFocusUserInfoKey"
}
Using this is simple. At the top of your SwiftUI View hierarchy, inject something into the binding in the environment:
struct TopView: View {
#State var focus: AccessibilityFocusTarget?
var body: some View {
FirstPage()
.environment(\.lastAccessibilityFocus, $focus)
}
}
And for any Views within the hierarchy that might be candidates for programmatic focus, just use the modifier to associate it with a AccessibilityFocusTarget enum value:
Title()
.a11yFocus(targetValue: .title)
ShareButton()
.a11yFocus(targetValue: .shareButton)
Nothing else is need in any of those child views - all the heavy lifting is handled in the modifier!
I am doing it with a Wrapper View:
#available(iOS 15, *)
public struct AccessibilityFocusableView<Content: View>: View {
#AccessibilityFocusState private var isFocused: Bool
private var requestFocusPublisher: AnyPublisher<Void, Never>
private var content: Content
public init(
requestFocusPublisher: AnyPublisher<Void, Never>,
#ViewBuilder content: () -> Content
) {
self.requestFocusPublisher = requestFocusPublisher
self.content = content()
}
public var body: some View {
self.content
.accessibilityFocused(self.$isFocused)
.onReceive(self.requestFocusPublisher) {
self.isFocused = true
}
}
}
And using it like that:
if #available(iOS 15, *) {
AccessibilityFocusableView(shouldRequestFocus: myPublisher) {
myView
}
} else {
myView
}

SwiftUI - Create popup that displays an updating progress message when executing long code

I am creating an app that has some code that takes a bit to execute. I want it to hang the app as I don't want the user to make any changes when it's executing. It's a Multiplatform app so when executing in macOS it automatically changes the mouse to a rolling ball image so that works-ish. In iOS there's no feedback at all. I want to create a popup (thinking alert but not too fussy) that displays an updating message showing the user what's happening and making it obvious they have to wait.
Right now my View calls a class that executes the code so I wanted to somehow pass a variable that gets updated in the class but is visible in the View in real-time. Ideally I would want to be able to use this and call different methods each time from other Views but still use a popup with messages updating the user while the code executes.
To simplify this I created a mini project but I can't get it to work on either the macOS OR iOS as the View (app) isn't updated until after the code finishes executing (also have print statements to know what's happening). I've been trying #StateObject, #Published, ObservableObject, etc to no avail.
Code: ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#StateObject var progress = myProgress()
#State var showProgress:Bool = false
var body: some View {
NavigationView {
VStack {
Button(action: {
print("Pressed Button 1")
showProgress = true
}, label: {
Text("Execute Option")
})
.sheet(isPresented: $showProgress, content: {
Text(progress.message)
.onAppear() {
Task {
let longMethod = longMethodsCalled(currProgress: progress)
print("About to call method - \(progress.message)")
let error = longMethod.exampleOne(title: "My Passed In Title")
// Here I can use the returned value if it's an object or now if it passed if it's String?
print("Error: \(error ?? "No error")")
print("after printing error - \(progress.message)")
}
// If this is commented out it just shows the last message after code finishes
showProgress = false
}
})
}
}
}
}
Other file: longMethodsCalled.swift
import Foundation
import SwiftUI
class myProgress: ObservableObject {
#Published var message = "progressing..."
func changeMessage(newMessage:String) {
message = newMessage
print("Message changing. Now: \(message)")
self.objectWillChange.send()
}
}
class longMethodsCalled {
#State var progress: myProgress
init(currProgress:myProgress) {
progress = currProgress
}
public func exampleOne(title:String) -> String? {
print("in example one - \(title)")
progress.changeMessage(newMessage: "Starting example one")
sleep(1)
print("after first sleep")
progress.changeMessage(newMessage: "Middle of example one")
sleep(1)
progress.changeMessage(newMessage: "About to return - example one")
return "result of example one"
}
}
I guess I'm wondering if this is even possible? And if so how can I go about it. I can't tell if I'm close or completely out to lunch. I would REALLY love a way to update my users when my code executes.
Thanks for any and all help.
Here is an example using binding to do all view update in another struct. It is using async and await. For the sleep(), it use Task.sleep which does not lock queues.
struct LongMethodCallMessage: View {
#State var showProgress:Bool = false
#State var progressViewMessage: String = "will do something"
var body: some View {
NavigationView {
VStack {
Button(action: {
print("Pressed Button 1")
progressViewMessage = "Pressed Button 1"
showProgress = true
}, label: {
// text will be return value
// so one can see that it ran
Text(progressViewMessage)
})
.sheet(isPresented: $showProgress, content: {
// create the vue that will display the progress
TheLongTaskView(progressViewMessage: $progressViewMessage, showProgress: $showProgress)
})
}
}
}
}
struct TheLongTaskView: View, LongMethodsCalledMessage {
#Binding var progressViewMessage: String
#Binding var showProgress: Bool
var body: some View {
Text(progressViewMessage)
.onAppear() {
// create the task setting this as delegate
// to receive message update
Task {
let longMethod = LongMethodsCalled(delegate: self)
print("About to call method - \(progressViewMessage)")
let error = await longMethod.exampleOne(title: "My Passed In Title")
// Here I can use the returned value if it's an object or now if it passed if it's String?
print("Error: \(error ?? "No error")")
print("after printing error - \(progressViewMessage)")
// save the error message and close view
progressViewMessage = error!
showProgress = false
}
}
}
// updating the text
func changeMessage(newMessage:String) {
print("changeMessage: \(newMessage)")
progressViewMessage = newMessage
}
}
// a protocol to update the text in the long running task
protocol LongMethodsCalledMessage {
func changeMessage(newMessage:String)
}
class LongMethodsCalled {
var delegate: LongMethodsCalledMessage
init(delegate: LongMethodsCalledMessage) {
self.delegate = delegate
}
// make the method async
public func exampleOne(title:String) async -> String? {
print("in example one - \(title)")
self.delegate.changeMessage(newMessage: "Starting example one")
// this wait enable the text to update
// the sleep() may lock and prevent main queue to run
try! await Task.sleep(nanoseconds: 2_000_000_000)
print("after first sleep")
self.delegate.changeMessage(newMessage: "Middle of example one")
try! await Task.sleep(nanoseconds: 2_000_000_000)
print("after second sleep")
self.delegate.changeMessage(newMessage: "About to return - example one")
return "result of example one"
}
}

Swift UI - Using UIVIewControllerRepresentable to update logs

The logic I'm trying to create for my logging in the app is:
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.
I've created a log view model which allows the log to be set and then appends to a log array and then get.
The logs are set through actions in callbacks from various view controllers and actions from the user.
currently I have the logs being retrieved in the UIViewControllerRepresentable - updateUIViewController method.
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)
}
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
subscribe to the notifications
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.
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

How to re-use the same FetchRequest, rather than the same request in several views?

I use the same fetch request in multiple top level views under ContentView. I mean the same entity / predicate etc.
Previously I tried doing the request once in ContentView and passing it as an array through several layers of views. However, this stopped changes, deletes etc, being propagated across other views.
I just wondered if there is another approach which would work ?
I'm thinking that some kind of singleton approach might work, but I'm worried about the performance implications, however this might outweigh having to run the request several times.
Also, I wondered about passing a request results rather than array?
However having to pass this around seems ugly.
You can use the Environment to pass your models to children without having to passing an array through several layers of views. You start by creating your own EnvirnomentKey
public struct ModelEnvironmentKey: EnvironmentKey {
public static var defaultValue: [Model] = []
}
public extension EnvironmentValues {
var models: [Model] {
get { self[ModelEnvironmentKey] }
set { self[ModelEnvironmentKey] = newValue }
}
}
public extension View {
func setModels(_ models: [Model]) -> some View {
environment(\.models, models)
}
}
I like using ViewModifiers to setup my environment, following the Single-Responsibility Principle:
struct ModelsLoaderViewModifier: ViewModifier {
#FetchRequest(entity: Model(), sortDescriptors: [])
var models: FetchedResults<Model>
func body(content: Content) -> some View {
content
.setModels(Array(models))
}
}
extension View {
func loadModels() -> some View {
modifier(ModelsLoaderViewModifier)
}
}
I then would add this modifier pretty high on the view hierarchy.
#main
struct BudgetApp: App {
#ObservedObject var persistenceManager = PersistenceManager(usage: .main)
let startup = Startup()
var body: some Scene {
WindowGroup {
ContentView()
.loadModels()
}
}
}
Now ContentView can read from the environment:
struct ContentView: View {
#Environment(\.models) var models
var body: some View {
List {
ForEach(models) { model in
Text(model.name)
}
}
}
}

SwiftUI - How can I call a Function in my View?

I am using SwiftUI/Swift for a week now, and I love it. Now I have a problem. I want to call a Function from my View, but I get this Error
Type '()' cannot conform to 'View'; only struct/enum/class types can conform to protocols
This is the Code:
struct testView: View {
var body: some View {
VStack {
Text("TextBox")
Text("SecondTextBox")
self.testFunction()
}
}
func testFunction() {
print("This is a Text.")
}
}
I don't get it. In other languages its much simpler and could work that way. Can anybody help me please? Swift is pretty new to me :D
Meanwhile here are the places (not all) where/how you can call a function
init() {
self.testFunction() // non always good, but can
}
var body: some View {
self.testFunction() // 1)
return VStack {
Text("TextBox")
.onTapGesture {
self.testFunction() // 2)
}
Text("SecondTextBox")
}
.onAppear {
self.testFunction() // 3)
}
.onDisappear {
self.testFunction() // 4)
}
}
... and so on
An additional method:
Testing with Swift 5.8, you can also stick in a let _ = self.testFunction().
eg
(this is extra-contrived so that it's possible to see the effect in Preview, because print() doesn't happen in Preview, at least for me)
import SwiftUI
class MyClass {
var counter = 0
}
struct ContentView: View {
var myClass: MyClass
var body: some View {
VStack {
Text("TextBox counter = \(myClass.counter)")
// v--------------------------------------------
//
let _ = self.testFunction() // compiles happily
// self.testFunction() // does not compile
//
// ^--------------------------------------------
Text("TextBox counter = \(myClass.counter)")
}
}
func testFunction() {
print("This is a Test.")
myClass.counter += 1
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(myClass: MyClass())
}
}
Using Swift 5.3 and Xcode 12.4
I use a little extension to debug inside the Views (VStack in the example), e.g. to inspect a geometryReader.size. It could be used to call any function in the View as follows:
NOTE: For debugging purposes only. I don't recommend including code like this in any production code
VStack {
Text.console("Hello, World")
Text("TEXT"
}
extension Text {
static func console<T>(_ value: T) -> EmptyView {
print("\(value)")
return EmptyView()
}
}
This will print "Hello, World" to the console.....
There is a reason, when writing in swift UI (iOS Apps), that there is a View protocol that needs to be followed. Calling functions from inside the structure is not compliant with the protocol.
The best thing you can do is the .on(insertTimeORAction here).
Read more about it here

Resources