siteline / swiftui-introspect

Introspect underlying UIKit/AppKit components from SwiftUI
MIT License
5.67k stars 351 forks source link

v0.2.2: addSubview fires multiple times #199

Closed bdashore3 closed 1 year ago

bdashore3 commented 1 year ago

Hi there, I recently updated to Introspect v0.2.2 and had an issue when using addSubview that I've never seen before. Basically, when I switch back and forth between views that use addSubview in an introspect call (example: TabView tabs), multiple subviews are overlaid on top of each other causing application lag and weird UI bugs.

I included my sample code below to showcase this problem. I basically add a custom scope bar with a segmented picker to the underlying UISearchController of searchable. To reproduce this bug:

  1. Launch an app on iOS 15+ (because of Searchable)
  2. Switch back and forth between the Home and Search tabs
  3. Observe the picker getting progressively whiter or darker depending on the system theme
  4. Try using the picker buttons after switching between tabs, you'll see lag

I reverted back to v0.2.1 and this issue did not occur. Am I supposed to change something in my code for v0.2.2 compatability or is this a mistake in the library?

struct MainView: View {
    enum ViewTab {
        case home
        case search
    }

    @State private var selectedTab: ViewTab = .home

    var body: some View {
        TabView(selection: $selectedTab) {
            InternalView()
                .tabItem {
                    Label("Home", systemImage: "house")
                }
                .tag(ViewTab.home)
            InternalView()
                .tabItem {
                    Label("Search", systemImage: "magnifyingglass")
                }
                .tag(ViewTab.search)
        }
    }
}

struct InternalView: View {
    @State private var searchText = ""

    var body: some View {
        NavigationView {
            List {
                ForEach(1..<100) { num in
                    Text(String(num))
                }
            }
            .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
            .navigationBarTitle("Search", displayMode: .large)
            .introspectSearchController { searchController in
                searchController.hidesNavigationBarDuringPresentation = true
                searchController.searchBar.showsScopeBar = true
                searchController.searchBar.scopeButtonTitles = [""]

                (searchController.searchBar.value(forKey: "_scopeBar") as? UIView)?.isHidden = true

                let hostingController = UIHostingController(rootView: FilterView())
                hostingController.view.translatesAutoresizingMaskIntoConstraints = false
                hostingController.view.backgroundColor = .clear

                guard let containerView = searchController.searchBar.value(forKey: "_scopeBarContainerView") as? UIView else {
                    return
                }
                searchController.navigationItem.hidesSearchBarWhenScrolling = false
                containerView.addSubview(hostingController.view)

                NSLayoutConstraint.activate([
                    hostingController.view.widthAnchor.constraint(equalTo: containerView.widthAnchor),
                    hostingController.view.topAnchor.constraint(equalTo: containerView.topAnchor),
                    hostingController.view.heightAnchor.constraint(equalTo: containerView.heightAnchor)
                ])
            }
            .introspectNavigationController { navigationController in
                navigationController.navigationBar.prefersLargeTitles = true
                navigationController.navigationBar.sizeToFit()
            }
        }
    }
}

struct FilterView: View {
    @State private var textName = "first"
    @State private var secondTextName = "First"
    var body: some View {
        Picker("", selection: $textName) {
            Text("First").tag("first")
            Text("Second").tag("second")
        }
        .pickerStyle(.segmented)
    }
}
davdroman commented 1 year ago

Hi @bdashore3. I think in general what we're facing now that Introspect is more resilient to SwiftUI's weird view lifecycle thanks to #165, #192 and #196 is that we should expect multiple calls to the customize callback to be performed. So this needs to be accounted for in situations where you don't just mutate some properties in your introspected view, but when you add new views to the hierarchy as they'll pile up indefinitely as you're seeing here.

I'll take a look at your example and come back to you shortly.

davdroman commented 1 year ago

@bdashore3 here's the solution:

struct MainView: View {
    enum ViewTab {
        case home
        case search
    }

    @State private var selectedTab: ViewTab = .home

    var body: some View {
        TabView(selection: $selectedTab) {
            InternalView()
                .tabItem {
                    Label("Home", systemImage: "house")
                }
                .tag(ViewTab.home)
            InternalView()
                .tabItem {
                    Label("Search", systemImage: "magnifyingglass")
                }
                .tag(ViewTab.search)
        }
    }
}

struct InternalView: View {
    @State private var searchText = ""

    var body: some View {
        NavigationView {
            List {
                ForEach(1..<100) { num in
                    Text(String(num))
                }
            }
            .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
            .navigationBarTitle("Search", displayMode: .large)
            .customScopeBar()
        }
    }
}

extension View {
    func customScopeBar() -> some View {
        self.modifier(CustomScopeBarModifier())
    }
}

struct CustomScopeBarModifier: ViewModifier {
    @State private var hostingController: UIViewController?

    func body(content: Content) -> some View {
        content
            .introspectSearchController { searchController in
                // if we've already set up the hosting controller before, don't do this at all
                guard hostingController == nil else { return }

                searchController.hidesNavigationBarDuringPresentation = true
                searchController.searchBar.showsScopeBar = true
                searchController.searchBar.scopeButtonTitles = [""]

                (searchController.searchBar.value(forKey: "_scopeBar") as? UIView)?.isHidden = true

                let hostingController = UIHostingController(rootView: FilterView())
                hostingController.view.translatesAutoresizingMaskIntoConstraints = false
                hostingController.view.backgroundColor = .clear

                guard let containerView = searchController.searchBar.value(forKey: "_scopeBarContainerView") as? UIView else {
                    return
                }
                searchController.navigationItem.hidesSearchBarWhenScrolling = false
                containerView.addSubview(hostingController.view)

                NSLayoutConstraint.activate([
                    hostingController.view.widthAnchor.constraint(equalTo: containerView.widthAnchor),
                    hostingController.view.topAnchor.constraint(equalTo: containerView.topAnchor),
                    hostingController.view.heightAnchor.constraint(equalTo: containerView.heightAnchor)
                ])

                // saves the hosting controller's reference for the check at the top
                self.hostingController = hostingController
            }
            .introspectNavigationController { navigationController in
                navigationController.navigationBar.prefersLargeTitles = true
                navigationController.navigationBar.sizeToFit()
            }
    }
}

struct FilterView: View {
    @State private var textName = "first"
    @State private var secondTextName = "First"
    var body: some View {
        Picker("", selection: $textName) {
            Text("First").tag("first")
            Text("Second").tag("second")
        }
        .pickerStyle(.segmented)
    }
}

Let me know if you have any questions.

There's still a little UI jolt when switching tabs for the first time due to the customization happening on the next thread hop after the search view controller is actually created, but after that it looks totally fine. I have some ideas on how to get rid of the thread hop but I need to spend some time on it. At least now I have a reliable reproducible scenario to work on :)

bdashore3 commented 1 year ago

I thought that was the case. Thanks for the tip! Closing this issue.