Why don't changes to #ObservedObject update a UIViewRepresentable? - ios

I have this simple example where I'm creating an #ObservedObject in a parent view and passing it to a child UIViewRepresentable. When I click "Button", it modifies the #ObservableObject but the child view never gets updated (i.e updateUIView is never called). Is there a different way to do this?
import SwiftUI
class UpdateViewState: ObservableObject {
#Published var words = ["A", "B", "C"]
func addWord(word: String) {
print("added word")
words.append(word)
}
}
struct UpdateView: View {
#ObservedObject private var state = UpdateViewState()
var body: some View {
VStack {
UpdateViewRepresentable(state: state)
Text("Button").onTapGesture {
self.state.addWord(word: "A")
}
}
}
}
struct UpdateViewRepresentable: UIViewRepresentable {
#ObservedObject var state: UpdateViewState
func makeUIView(context: Context) -> UILabel {
let view = UILabel()
view.text = "Hello World"
return view
}
func updateUIView(_ uiView: UILabel, context: UIViewRepresentableContext<UpdateViewRepresentable>) {
print("updateUIView")
uiView.text = state.words.joined(separator: ", ")
}
}

try this:
public final class UpdateViewState: ObservableObject {
#Published var words = ["A", "B", "C"]
func addWord(word: String) {
print("added word ", words)
words.append(word)
}
}
struct ContentView: View {
#EnvironmentObject private var state: UpdateViewState
var body: some View {
VStack {
UpdateViewRepresentable(state: .constant(state))
Text("Button").onTapGesture {
self.state.addWord(word: "A")
}
}.onAppear() {
self.state.words.append("aha")
}
}
}
struct UpdateViewRepresentable: UIViewRepresentable {
#Binding var state: UpdateViewState
func makeUIView(context: Context) -> UILabel {
let view = UILabel()
view.text = "Hello World"
return view
}
func updateUIView(_ uiView: UILabel, context: UIViewRepresentableContext<UpdateViewRepresentable>) {
print("updateUIView")
uiView.text = state.words.joined(separator: ", ")
}
}

This may help you in a very simple way:
var body: some View {
VStack {
UpdateViewRepresentable(state: state)
Text("Button").onTapGesture {
self.state.addWord(word: "A")
self.state.objectWillChange.send()
}
}
}
Try to use the line I added, this will the View to update itself. Make sure you use:
import Combine

Related

SwiftUI three column navigation layout not closing when selecting same item

I'm using a three column navigation layout and facing the issue, that when selecting the same second column's item, the drawers won't close. If I take the files app as reference, selecting the same item again will close the drawer. Can someone tell me what's the issue? And is drawer the correct term?
Thanks in advance, Carsten
Code to reproduce:
import SwiftUI
extension UISplitViewController {
open override func viewDidLoad() {
preferredDisplayMode = .twoBesideSecondary
}
}
#main
struct TestApp: App {
#Environment(\.factory) var factory
var body: some Scene {
WindowGroup {
NavigationView {
ContentView(viewModel: factory.createVM1())
ContentView2(viewModel: factory.createVM2())
EmptyView()
}
}
}
}
struct FactoryKey: EnvironmentKey {
static let defaultValue: Factory = Factory()
}
extension EnvironmentValues {
var factory: Factory {
get {
return self[FactoryKey.self]
}
set {
self[FactoryKey.self] = newValue
}
}
}
class Factory {
func createVM1() -> ViewModel1 {
ViewModel1()
}
func createVM2() -> ViewModel2 {
ViewModel2()
}
func createVM3(from item: ViewModel2.Model) -> ViewModel3 {
ViewModel3(item: item)
}
}
class ViewModel1: ObservableObject {
struct Model: Identifiable {
let id: UUID = UUID()
let name: String
}
#Published var items: [Model]
init() {
items = (1 ... 4).map { Model(name: "First Column Item \($0)") }
}
}
struct ContentView: View {
#Environment(\.factory) var factory
#StateObject var viewModel: ViewModel1
var body: some View {
List {
ForEach(viewModel.items) { item in
NavigationLink(
destination: ContentView2(viewModel: factory.createVM2()),
label: {
Text(item.name)
})
}
}
}
}
class ViewModel2: ObservableObject {
struct Model: Identifiable {
let id: UUID = UUID()
let name: String
}
#Published var items: [Model]
init() {
items = (1 ... 4).map { Model(name: "Second Column Item \($0)") }
}
}
struct ContentView2: View {
#Environment(\.factory) var factory
#StateObject var viewModel: ViewModel2
var body: some View {
List {
ForEach(viewModel.items) { item in
NavigationLink(
destination: Detail(viewModel: factory.createVM3(from: item)),
label: {
Text(item.name)
})
}
}
}
}
class ViewModel3: ObservableObject {
let item: ViewModel2.Model
init(item: ViewModel2.Model) {
self.item = item
}
}
struct Detail: View {
#StateObject var viewModel: ViewModel3
var body: some View {
Text(viewModel.item.name)
}
}

#Published not updating view

I'm using a barcode scanner in my app, and to show each product on a view when it's barcode is scanned.
I have a sheet that show's details of the product and I want it to reload when ScannedCode is updated.
For each class that uses the barcode, I declare it like:
#ObservedObject var scannedCode: ScannedCode
But when I change the value, the views don't update.
I declare ScannedCode in my contentView:
class ScannedCode: ObservableObject {
#Published var barcode = ""
}
class dbProduct: ObservableObject {
#Published var result = Result()
}
struct ContentView: View {
let scannedCode = ScannedCode()
let product = dbProduct()
var body: some View {
ZStack {
ScannerView(scannedCode: scannedCode) //Starts the scanner
FoundItemSheet(scannedCode: scannedCode, product: product)
}
}
}
When the scanner finds a product, it updates the barcode in it's Coordinator:
class Coordinator: BarcodeScannerCodeDelegate, BarcodeScannerErrorDelegate {
#ObservedObject var scannedCode: ScannedCode
private var scannerView: ScannerView
init(_ scannerView: ScannerView, barcode: ScannedCode) {
self.scannerView = scannerView
self.scannedCode = barcode
}
func scanner(_ controller: BarcodeScannerViewController, didCaptureCode code: String, type: String) {
self.scannedCode.barcode = code //Updates the barcode here
controller.resetWithError(message: "Error message")
}
func scanner(_ controller: BarcodeScannerViewController, didReceiveError error: Error) {
print(error)
}
}
FoundItemSheet calls BottomSheetView, which displays the product. productDataView calculates which data to be shown on BottomSheetView as its content().
When the body is loaded for BottomSheetView() I call the API and store the data into an #ObservedObject so productDataView can access it.
.onAppear{
DispatchQueue.main.async {
let hashedValue = scannedCode.barcode.hashedValue("Ls75O8z1q9Ep9Kz0")
self.loadData(url: *API Call with barcode*) {
//...Load data converts from JSON and stores the product
This is where I suspect it could be going wrong, as the barcode that's changed in the scanner Coordinator isn't being updated here.
EDIT:
ScannerView
extension UINavigationController {
open override var preferredStatusBarStyle: UIStatusBarStyle {
return topViewController?.preferredStatusBarStyle ?? .default
}
}
struct ScannerView: UIViewControllerRepresentable {
#ObservedObject var scannedCode: ScannedCode
func makeCoordinator() -> Coordinator {
Coordinator(self, barcode: scannedCode)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<ScannerView>) -> BarcodeScannerViewController {
return createAndConfigureScanner(context: context)
}
func updateUIViewController(_ uiViewController: BarcodeScannerViewController, context: UIViewControllerRepresentableContext<ScannerView>) {
uiViewController.reset(animated: false)
}
private func createAndConfigureScanner(context: UIViewControllerRepresentableContext<ScannerView>) -> BarcodeScannerViewController {
let barcodeVC = BarcodeScannerViewController()
barcodeVC.codeDelegate = context.coordinator
barcodeVC.errorDelegate = context.coordinator
return barcodeVC
}
}

SwiftUI how add custom modifier with callback

In SwiftUI you can wrote code like this:
List {
ForEach(users, id: \.self) { user in
Text(user)
}
.onDelete(perform: delete)
}
I try to add functionality with .onDelete syntax method to my custom component:
struct MyComponen: View {
#Binding var alert: String
.
.
.
}
I try to add this ability with extension:
extension MyComponent {
func foo() -> Self {
var copy = self
copy.alert = "Hohoho"
return copy
}
func onDelete() -> Void {
}
}
How can I change state (or callback function with):
struct ContentView: View {
var body: some View {
Group {
MyComponent() //-> with alert = "state 1"
MyComponent().foo() //-> with alert = "state 2"
MyComponent().foo(action: actionFunction) //-> how do this?
}
}
}
Continuing your approach this might look like below. As alternate it is possible to use ViewModifier protocol.
struct MyComponen: View {
#Binding var alert: String
var action: (() -> Void)?
var body: some View {
VStack {
Text("Alert: \(alert)")
if nil != action {
Button(action: action!) {
Text("Action")
}
}
}
}
}
extension MyComponen {
func foo(perform action: #escaping () -> Void ) -> Self {
var copy = self
copy.action = action
return copy
}
}
struct TestCustomModifier: View {
#State var message = "state 2"
var body: some View {
VStack {
MyComponen(alert: .constant("state 1"))
MyComponen(alert: $message).foo(perform: {
print(">> got action")
})
}
}
}
struct TestCustomModifier_Previews: PreviewProvider {
static var previews: some View {
TestCustomModifier()
}
}

iOS SwiftUI Searchbar and REST-API

I'm experimenting with SwiftUI and would like to fetch an update from my REST API with a search string.
However, I'm not sure how to bring the two components together now.
I hope you have an idea.
Here my Code:
struct ContentView: View {
#State private var searchTerm: String = ""
#ObservedObject var gameData: GameListViewModel = GameListViewModel(searchString: ### SEARCH STRING ???? ###)
var body: some View {
NavigationView{
Group{
// Games werden geladen...
if(self.gameData.isLoading) {
LoadingView()
}
// Games sind geladen:
else{
VStack{
// Suche:
searchBarView(text: self.$searchTerm)
// Ergebnisse:
List(self.gameData.games){ game in
NavigationLink(destination: GameDetailView(gameName: game.name ?? "0", gameId: 0)){
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(game.name ?? "Kein Name gefunden")
.font(.headline)
Text("Cover: \(game.cover?.toString() ?? "0")")
.font(.subheadline)
.foregroundColor(.gray)
}
}
}
}
}
}
}
.navigationBarTitle(Text("Games"))
}
}
}
And the search bar implementation:
import Foundation
import SwiftUI
struct searchBarView: UIViewRepresentable {
#Binding var text:String
class Coordinator: NSObject, UISearchBarDelegate {
#Binding var text: String
init(text: Binding<String>){
_text = text
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
print(searchText)
text = searchText
}
}
func makeCoordinator() -> searchBarView.Coordinator {
return Coordinator(text: $text)
}
func makeUIView(context: UIViewRepresentableContext<searchBarView>) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<searchBarView>) {
uiView.text = text
}
}
The search text should be inside the view model.
final class GameListViewModel: ObservableObject {
#Published var isLoading: Bool = false
#Published var games: [Game] = []
var searchTerm: String = ""
private let searchTappedSubject = PassthroughSubject<Void, Error>()
private var disposeBag = Set<AnyCancellable>()
init() {
searchTappedSubject
.flatMap {
self.requestGames(searchTerm: self.searchTerm)
.handleEvents(receiveSubscription: { _ in
DispatchQueue.main.async {
self.isLoading = true
}
},
receiveCompletion: { comp in
DispatchQueue.main.async {
self.isLoading = false
}
})
.eraseToAnyPublisher()
}
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.assign(to: \.games, on: self)
.store(in: &disposeBag)
}
func onSearchTapped() {
searchTappedSubject.send(())
}
private func requestGames(searchTerm: String) -> AnyPublisher<[Game], Error> {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
return Fail(error: URLError(.badURL))
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.mapError { $0 as Error }
.decode(type: [Game].self, decoder: JSONDecoder())
.map { searchTerm.isEmpty ? $0 : $0.filter { $0.title.contains(searchTerm) } }
.eraseToAnyPublisher()
}
}
Each time onSearchTapped is called, it fires a request for new games.
There's plenty of things going on here - let's start from requestGames.
I'm using JSONPlaceholder free API to fetch some data and show it in the List.
requestGames performs the network request, decodes [Game] from the received Data. In addition to that, the returned array is filtered using the search string (because of the free API limitation - in a real world scenario you'd use a query parameter in the request URL).
Now let's have a look at the view model constructor.
The order of the events is:
Get the "search tapped" subject.
Perform a network request (flatMap)
Inside the flatMap, loading logic is handled (dispatched on the main queue as isLoading uses a Publisher underneath, and there will be a warning if a value is published on a background thread).
replaceError changes the error type of the publisher to Never, which is a requirement for the assign operator.
receiveOn is necessary as we're probably still in a background queue, thanks to the network request - we want to publish the results on the main queue.
assign updates the array games on the view model.
store saves the Cancellable in the disposeBag
Here's the view code (without the loading, for the sake of the demo):
struct ContentView: View {
#ObservedObject var viewModel = GameListViewModel()
var body: some View {
NavigationView {
Group {
VStack {
SearchBar(text: $viewModel.searchTerm,
onSearchButtonClicked: viewModel.onSearchTapped)
List(viewModel.games, id: \.title) { game in
Text(verbatim: game.title)
}
}
}
.navigationBarTitle(Text("Games"))
}
}
}
Search bar implementation:
struct SearchBar: UIViewRepresentable {
#Binding var text: String
var onSearchButtonClicked: (() -> Void)? = nil
class Coordinator: NSObject, UISearchBarDelegate {
let control: SearchBar
init(_ control: SearchBar) {
self.control = control
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
control.text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
control.onSearchButtonClicked?()
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
uiView.text = text
}
}
There is no need to get UIKit involved, you can declare a simple search bar like this:
struct SearchBar: View {
#State var searchString: String = ""
var body: some View {
HStack {
TextField(
"Start typing",
text: $searchString,
onCommit: performSearch)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: performSearch) {
Image(systemName: "magnifyingglass")
}
} .padding()
}
func performSearch() {
}
}
and then place the search logic inside performSearch().

SwiftUI View - viewDidLoad()?

Trying to load an image after the view loads, the model object driving the view (see MovieDetail below) has a urlString. Because a SwiftUI View element has no life cycle methods (and there's not a view controller driving things) what is the best way to handle this?
The main issue I'm having is no matter which way I try to solve the problem (Binding an object or using a State variable), my View doesn't have the urlString until after it loads...
// movie object
struct Movie: Decodable, Identifiable {
let id: String
let title: String
let year: String
let type: String
var posterUrl: String
private enum CodingKeys: String, CodingKey {
case id = "imdbID"
case title = "Title"
case year = "Year"
case type = "Type"
case posterUrl = "Poster"
}
}
// root content list view that navigates to the detail view
struct ContentView : View {
var movies: [Movie]
var body: some View {
NavigationView {
List(movies) { movie in
NavigationButton(destination: MovieDetail(movie: movie)) {
MovieRow(movie: movie)
}
}
.navigationBarTitle(Text("Star Wars Movies"))
}
}
}
// detail view that needs to make the asynchronous call
struct MovieDetail : View {
let movie: Movie
#State var imageObject = BoundImageObject()
var body: some View {
HStack(alignment: .top) {
VStack {
Image(uiImage: imageObject.image)
.scaledToFit()
Text(movie.title)
.font(.subheadline)
}
}
}
}
We can achieve this using view modifier.
Create ViewModifier:
struct ViewDidLoadModifier: ViewModifier {
#State private var didLoad = false
private let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content.onAppear {
if didLoad == false {
didLoad = true
action?()
}
}
}
}
Create View extension:
extension View {
func onLoad(perform action: (() -> Void)? = nil) -> some View {
modifier(ViewDidLoadModifier(perform: action))
}
}
Use like this:
struct SomeView: View {
var body: some View {
VStack {
Text("HELLO!")
}.onLoad {
print("onLoad")
}
}
}
I hope this is helpful. I found a blogpost that talks about doing stuff onAppear for a navigation view.
Idea would be that you bake your service into a BindableObject and subscribe to those updates in your view.
struct SearchView : View {
#State private var query: String = "Swift"
#EnvironmentObject var repoStore: ReposStore
var body: some View {
NavigationView {
List {
TextField($query, placeholder: Text("type something..."), onCommit: fetch)
ForEach(repoStore.repos) { repo in
RepoRow(repo: repo)
}
}.navigationBarTitle(Text("Search"))
}.onAppear(perform: fetch)
}
private func fetch() {
repoStore.fetch(matching: query)
}
}
import SwiftUI
import Combine
class ReposStore: BindableObject {
var repos: [Repo] = [] {
didSet {
didChange.send(self)
}
}
var didChange = PassthroughSubject<ReposStore, Never>()
let service: GithubService
init(service: GithubService) {
self.service = service
}
func fetch(matching query: String) {
service.search(matching: query) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let repos): self?.repos = repos
case .failure: self?.repos = []
}
}
}
}
}
Credit to: Majid Jabrayilov
Fully updated for Xcode 11.2, Swift 5.0
I think the viewDidLoad() just equal to implement in the body closure.
SwiftUI gives us equivalents to UIKit’s viewDidAppear() and viewDidDisappear() in the form of onAppear() and onDisappear(). You can attach any code to these two events that you want, and SwiftUI will execute them when they occur.
As an example, this creates two views that use onAppear() and onDisappear() to print messages, with a navigation link to move between the two:
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView()) {
Text("Hello World")
}
}
}.onAppear {
print("ContentView appeared!")
}.onDisappear {
print("ContentView disappeared!")
}
}
}
ref: https://www.hackingwithswift.com/quick-start/swiftui/how-to-respond-to-view-lifecycle-events-onappear-and-ondisappear
I'm using init() instead. I think onApear() is not an alternative to viewDidLoad(). Because onApear is called when your view is being appeared. Since your view can be appear multiple times it conflicts with viewDidLoad which is called once.
Imagine having a TabView. By swiping through pages onApear() is being called multiple times. However viewDidLoad() is called just once.

Resources