Mock a SwiftUI view from another module - ios

I'm trying to test a SwiftUI view that has a subview from another module in its body:
import SwiftUI
import Abond
struct ProfileView: PresentableView, LoadedView {
#State var isLoading = true
public var body: some View {
Load(self) {
AbondProfile(onSuccess: self.onSubmitSuccess)
}
}
func load() -> Binding<Bool> {
ProfileApi.getProfileAccessToken() { result in
switch result {
case .success(let response):
Abond.accessToken = response.accessToken
case .failure(let error):
print("error getting token")
}
isLoading = false
}
return $isLoading
}
func onSubmitSuccess() {
print("success")
}
}
My question is: if I want to test the lifecycle of ProfileView without the actual AbondProfile view being built, is there a way to mock that? If it were a normal method I would inject a dependency object, but I don't know how to translate that to a struct initializer.
Abond is a Swift Package, so I can't modify AbondProfile. And I'd prefer to be able to test this with as little change to my view code as possible. I'm using XCTest.

As David Wheeler said, “Any problem in computer science can be solved with another level of indirection.”
In this case, one solution is to refer to AbondProfile indirectly, through a generic type parameter. We add a type parameter to ProfileView to replace the direct use of AbondProfile:
struct ProfileView<Content: View>: PresentableView, LoadedView {
#State var isLoading = true
#ViewBuilder var content: (_ onSuccess: #escaping () -> Void) -> Content
public var body: some View {
Load(self) {
content(onSubmitSuccess)
}
}
blah blah blah
}
We don't have to change current uses of ProfileView
if we provide a default initializer that uses AbondProfile:
extension ProfileView {
init() where Content == AbondProfile {
self.init { AbondProfile(onSuccess: $0) }
}
}
struct ProductionView: View {
var body: some View {
ProfileView() // This uses AbondProfile.
}
}
And in a test, we can provide a mock view:
struct TestView: View {
var body: some View {
ProfileView { onSuccess in
Text("a travesty of a mockery of a sham of a mockery of a travesty of two mockeries of a sham")
}
}
}

I accepted the other answer because it's a more proper solution, but I found that it actually works to just redefine the struct in your test file:
import XCTest
import Abond
import SwiftUI
// Mock for Abond.AbondProfile
public struct AbondProfile: View {
static var viewDidAppearCallback: (() -> Void)?
static var submit: (() -> Void)?
public init(onSuccess: (() -> Void)? = nil) {
AbondProfile.submit = onSuccess
}
public var body: some View {
Text(Abond.accessToken)
.onAppear {
AbondProfile.viewDidAppearCallback?()
}
}
}
class ProfileViewTests: BaseViewControllerTests {
private var viewController: UIViewController?
func testSucesss() {
let viewDidAppearExpectation = XCTestExpectation(description: "View did appear")
AbondProfile.viewDidAppearCallback = { viewDidAppearExpectation.fulfill() }
MockApi.mockRequest(ProfileApi.getProfileAccessToken, response: ProfileAccessToken(accessToken:"accessToken_123"))
initialize(viewController: UIHostingController(rootView: ProfileView()))
wait(for: [viewDidAppearExpectation], timeout: 10)
XCTAssertEqual(Abond.accessToken, "accessToken_123")
AbondProfile.submit!()
// etc.
}
}
I'm aware the static variables make the test brittle – but other than that, I'd be interested to hear if there are any other reasons not to do it this way.

Related

How to create an enum based .textStyle(.title) modifier for Text(...) components in SwiftUI?

I want to implement a modifier setting for Texts in a similar way as it already exists for Buttons.
Aka:
Button( ... )
.buttonStyle(.plain) // <-- .plain and not PlainStyle()
Problem
Of course I cannot use an opaque which is not really the same. If it would be a View I could wrap it in an AnyView but for ViewModifiers I need another solution.
Error: Function declares an opaque return type,but the return statements in its body do not have matching underlying types
Maybe it is a bonus idea to have something like a .textStyle(.title) modifier but in my eyes, it could reduce my code to write enormously.
Source
struct TitleStyle: ViewModifier {
func body(content: Content) -> some View {
...
}
}
struct BodyStyle: ViewModifier {
func body(content: Content) -> some View {
...
}
}
enum TextStyle {
case title
case body
// Error: Function declares an opaque return type,
// but the return statements in its body do not have matching underlying types
var modifier: some ViewModifier {
switch self
{
case .title: return TitleStyle()
case .body: return BodyStyle()
}
}
}
It works different way. As all this is around generics we need to restrict declarations for known concrete types.
So, having TitleStyle and BodyStyle declared and concrete, we can specify
extension ViewModifier where Self == TitleStyle {
static var title: TitleStyle { TitleStyle() }
}
extension ViewModifier where Self == BodyStyle {
static var body: BodyStyle { BodyStyle() }
}
and then declare extension to use above like
extension View {
func textStyle<Style: ViewModifier>(_ style: Style) -> some View {
ModifiedContent(content: self, modifier: style)
}
}
so as a result we can do as demo
struct Demo_Previews: PreviewProvider {
static var previews: some View {
Text("Demo")
.textStyle(.title)
}
}
Prepared with Xcode 13.4 / iOS 15.5
Test module in GitHub

onReceive callback not executing

So I am working on a view in SwiftUI which will update its state when an event is published.
The view looks like this:
struct MyView: View {
#EnvironmentObject var dataSource: DataSource
#State var data: [Model] = []
func refreshData() {
self.data = dataSource.getData()
}
var body: some View {
VStack {
List(self.data) { model in
Row(model: model)
}
}
.onAppear {
self.refreshData()
}
.onReceive(self.dataSource.didUpdate) { _ in
print("on receive")
self.refreshData()
}
}
}
class DataSource: ObservableObject {
var didUpdate: PassthroughSubject<Model,Never> ...
}
So with this setup, the onAppear block is called and works as expected. But the onReceive callback is never called. I have been able to verify that .send is being called correctly on the DataSource.didUpdate subject, but it appears the subscriber is not being notified.
Is there something I am missing to make this work?
As you are correctly declaring your DataSource as an observable object class, what you need now is to use the #Published property wrapper on you didUpdate variable. Then, with SwiftUI you can listen to it using .onChange(of:) { }. Just note that it does not work with computed vars: in such case, use the computed var to update the published var. Also, I assume you are correctly injecting your model instance in the environment (otherwise it will never work).
Like this:
struct MyView: View {
#EnvironmentObject var dataSource: DataSource // Remember to inject the specific instance in the environment
#State var data: [Model] = []
func refreshData() {
self.data = dataSource.getData()
}
var body: some View {
VStack {
List(self.data) { model in
Row(model: model)
}
}
.onAppear {
self.refreshData()
}
// .onReceive(self.dataSource.didUpdate) { _ in
// print("on receive")
.onChange(of: dataSource.didUpdate) { _ in // Listen to changes in ObservableObject property that publishes
print("on change")
self.refreshData()
}
}
}
class DataSource: ObservableObject {
// Note the property wrapper, but it does not work with computed vars:
// in such case, use the computed var to update the published var.
// Also: update it only in the main thread!
#Published var didUpdate: PassthroughSubject<Model,Never> ...
}

A View.environmentObject(_:) may be missing as an ancestor of this view - but not always…

I'm getting this error in production and can't find a way to reproduce it.
Fatal error > No ObservableObject of type PurchaseManager found. A
View.environmentObject(_:) for PurchaseManager may be missing as an
ancestor of this view. > PurchaseManager > SwiftUI
The crash comes from this view:
struct PaywallView: View {
#EnvironmentObject private var purchaseManager: PurchaseManager
var body: some View {
// Call to purchaseManager causing the crash
}
}
And this view is instantiated in subviews of the MainView
#main
struct MyApp: App {
let purchasesManager = PurchaseManager.shared
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(purchasesManager)
}
}
}
}
or, when called from a UIKit controller, from this controler:
final class PaywallHostingController: UIHostingController<AnyView> {
init() {
super.init(rootView:
AnyView(
PaywallView()
.environmentObject(PurchaseManager.shared)
)
)
}
#objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
I tested all the use cases that trigger the PaywallView to show up, and I never got a crash.
FWIW, the PurchaseManager looks like this:
public class PurchaseManager: ObservableObject {
static let shared = PurchaseManager()
init() {
setupRevenueCat()
fetchOfferings()
refreshPurchaserInfo()
}
}
Why would the ObservableObject go missing? In which circumstances?
The reason your problem is intermittent, is probably because the PurchaseManager init()
could finish
before all the data is setup properly, due to the "delays" of the
async functions in init(). So sometimes the data will be available
when the View wants it, and sometimes it will not be there and crash your app.
You could try the following approach that includes #atultw advice of using
StateObject.
import SwiftUI
#main
struct TestApp: App {
#StateObject var purchaseManager = PurchaseManager() // <-- here
var body: some Scene {
WindowGroup {
MainView()
.onAppear {
purchaseManager.startMeUp() // <-- here
}
.environmentObject(purchaseManager)
}
}
}
struct MainView: View {
#EnvironmentObject var purchaseManager: PurchaseManager
var body: some View {
Text("testing")
List {
ForEach(purchaseManager.offerings, id: \.self) { offer in
Text(offer)
}
}
}
}
public class PurchaseManager: ObservableObject {
#Published var offerings: [String] = []
// -- here --
func startMeUp() {
// setupRevenueCat()
fetchOfferings()
// refreshPurchaserInfo()
}
func fetchOfferings() {
DispatchQueue.main.asyncAfter(deadline: .now()+2) {
self.offerings = ["offer 1","offer 2","offer 3","offer 4"]
}
}
}
Try not to use the singleton pattern here (.shared), EnvironmentObject is meant to be a replacement for it. You should instantiate PurchasesManager in MyApp.
#main
struct MyApp: App {
#StateObject var purchasesManager = PurchaseManager()
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(purchasesManager)
}
}
}
}
without state object compiles fine but needed if you want child views to update automatically.
Doing those things with a dummy PurchasesManager runs fine for me.

Navigation bar disappears after the app comes to the foreground again

I'm using Parchment to add menu items at the top. The hierarchy of the main view is the following:
NavigationView
-> TabView
--> Parchment PagingView
---> NavigationLink(ChildView)
All works well going to the child view and then back again repeatedly. The issue happens when I go to ChildView, then go to the background/Home Screen then re-open. If I click back and then go to the child again the back button and the whole navigation bar disappears.
Here's code to replicate:
import SwiftUI
import Parchment
#main
struct ParchmentBugApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
TabView {
PagingView(items: [
PagingIndexItem(index: 0, title: "View 0"),
]) { item in
VStack {
NavigationLink(destination: ChildView()) {
Text("Go to child view")
}
}
.navigationBarHidden(true)
}
}
}
}
}
struct ChildView: View {
var body: some View {
VStack {
Text("Child View")
}
.navigationBarHidden(false)
.navigationBarTitle("Child View")
}
}
To replicate:
Launch and go to the child view
Click the home button to send the app to the background
Open the app again
Click on back
Navigate to the child view. The nav bar/back button are not there anymore.
What I noticed:
Removing the TabView makes the problem go away.
Removing PagingView also makes the problem go.
I tried to use a custom PagingController and played with various settings without success. Here's the custom PagingView if someone would like to tinker with the settings as well:
struct CustomPagingView<Item: PagingItem, Page: View>: View {
private let items: [Item]
private let options: PagingOptions
private let content: (Item) -> Page
/// Initialize a new `PageView`.
///
/// - Parameters:
/// - options: The configuration parameters we want to customize.
/// - items: The array of `PagingItem`s to display in the menu.
/// - content: A callback that returns the `View` for each item.
public init(options: PagingOptions = PagingOptions(),
items: [Item],
content: #escaping (Item) -> Page) {
self.options = options
self.items = items
self.content = content
}
public var body: some View {
PagingController(items: items, options: options,
content: content)
}
struct PagingController: UIViewControllerRepresentable {
let items: [Item]
let options: PagingOptions
let content: (Item) -> Page
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<PagingController>) -> PagingViewController {
let pagingViewController = PagingViewController(options: options)
return pagingViewController
}
func updateUIViewController(_ pagingViewController: PagingViewController, context: UIViewControllerRepresentableContext<PagingController>) {
context.coordinator.parent = self
if pagingViewController.dataSource == nil {
pagingViewController.dataSource = context.coordinator
} else {
pagingViewController.reloadData()
}
}
}
class Coordinator: PagingViewControllerDataSource {
var parent: PagingController
init(_ pagingController: PagingController) {
self.parent = pagingController
}
func numberOfViewControllers(in pagingViewController: PagingViewController) -> Int {
return parent.items.count
}
func pagingViewController(_: PagingViewController, viewControllerAt index: Int) -> UIViewController {
let view = parent.content(parent.items[index])
return UIHostingController(rootView: view)
}
func pagingViewController(_: PagingViewController, pagingItemAt index: Int) -> PagingItem {
return parent.items[index]
}
}
}
Tested on iOS Simulator 14.4 & 14.5, and device 14.5 beta 2.
Any tips or ideas are very much appreciated.
Okay I found the issue while debugging something else that was related to Parchment as well.
The issue is updateUIViewController() gets called each time the encompassing SwiftUI state changes (and when coming back to the foreground), and the PageController wrapper provided by the library will call reloadData() since the data source data has already been set. So to resolve this just remove/comment out the reloadData() call since the PageController will be re-built if the relevant state changes. The same issue was the cause for the bug I was debugging.
func updateUIViewController(_ pagingViewController: PagingViewController, context: UIViewControllerRepresentableContext<PagingController>) {
context.coordinator.parent = self
if pagingViewController.dataSource == nil {
pagingViewController.dataSource = context.coordinator
}
//else {
// pagingViewController.reloadData()
//}
}

SwiftUI : Dismiss modal from child view

I'm attempting to dismiss a modal after its intended action is completed, but I have no idea how this can be currently done in SwiftUI. This modal is triggered by a #State value change. Would it be possible to change this value by observing a notification of sorts?
Desired actions: Root -> Initial Modal -> Presents Children -> Dismiss modal from any child
Below is what I've tried
Error: Escaping closure captures mutating 'self' parameter
struct AContentView: View {
#State var pageSaveInProgress: Bool = false
init(pages: [Page] = []) {
// Observe change to notify of completed action
NotificationCenter.default.publisher(for: .didCompletePageSave).sink { (pageSaveInProgress) in
self.pageSaveInProgress = false
}
}
var body: some View {
VStack {
//ETC
.sheet(isPresented: $pageSaveInProgress) {
ModalWithChildren()
}
}
}
}
ModalWithChildren test action
Button(action: {
NotificationCenter.default.post(
name: .didCompletePageSave, object: nil)},
label: { Text("Close") })
You can receive messages through .onReceive(_:perform) which can be called on any view. It registers a sink and saves the cancellable inside the view which makes the subscriber live as long as the view itself does.
Through it you can initiate #State attribute changes since it starts from the view body. Otherwise you would have to use an ObservableObject to which change can be initiated from anywhere.
An example:
struct MyView : View {
#State private var currentStatusValue = "ok"
var body: some View {
Text("Current status: \(currentStatusValue)")
}
.onReceive(MyPublisher.currentStatusPublisher) { newStatus in
self.currentStatusValue = newStatus
}
}
A complete example
import SwiftUI
import Combine
extension Notification.Name {
static var didCompletePageSave: Notification.Name {
return Notification.Name("did complete page save")
}
}
struct OnReceiveView: View {
#State var pageSaveInProgress: Bool = true
var body: some View {
VStack {
Text("Usual")
.onReceive(NotificationCenter.default.publisher(for: .didCompletePageSave)) {_ in
self.pageSaveInProgress = false
}
.sheet(isPresented: $pageSaveInProgress) {
ModalWithChildren()
}
}
}
}
struct ModalWithChildren: View {
#State var presentChildModals: Bool = false
var body: some View {
Button(action: {
NotificationCenter.default.post(
name: .didCompletePageSave,
object: nil
)
}) { Text("Send message") }
}
}

Resources