diff --git a/Columbus.xcodeproj/project.pbxproj b/Columbus.xcodeproj/project.pbxproj index ce24edf..aa09984 100644 --- a/Columbus.xcodeproj/project.pbxproj +++ b/Columbus.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + B92A544022F3699C008FE261 /* CountryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B92A543F22F3699C008FE261 /* CountryListViewModel.swift */; }; B9397D4222DBC42E00E9D98E /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9397D4122DBC42E00E9D98E /* Extensions.swift */; }; B9397D4322DBC42E00E9D98E /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9397D4122DBC42E00E9D98E /* Extensions.swift */; }; B94B1AF921CD38B400FF1B72 /* Country.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F8788F21CD28EC00273F82 /* Country.swift */; }; @@ -65,6 +66,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + B92A543F22F3699C008FE261 /* CountryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryListViewModel.swift; sourceTree = ""; }; B9397D4122DBC42E00E9D98E /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; B94B1B0621CD38B400FF1B72 /* Columbus.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Columbus.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B96D496221CD3DC20010E69B /* Resources.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Resources.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -220,6 +222,7 @@ B9F8789421CD28EC00273F82 /* Configuration.swift */, B9F8788F21CD28EC00273F82 /* Country.swift */, B9F8788E21CD28EC00273F82 /* CountryListView.swift */, + B92A543F22F3699C008FE261 /* CountryListViewModel.swift */, B9B4EBCF22DA4889001346B0 /* CountryRow.swift */, B9B4EBFF22DBB215001346B0 /* CountryStore.swift */, B9397D4122DBC42E00E9D98E /* Extensions.swift */, @@ -507,6 +510,7 @@ buildActionMask = 2147483647; files = ( B9F8789D21CD28EC00273F82 /* Country.swift in Sources */, + B92A544022F3699C008FE261 /* CountryListViewModel.swift in Sources */, B9F8789C21CD28EC00273F82 /* CountryListView.swift in Sources */, B9F878A221CD28EC00273F82 /* Configuration.swift in Sources */, B9B4EBD022DA4889001346B0 /* CountryRow.swift in Sources */, diff --git a/Example/Source/SceneDelegate.swift b/Example/Source/SceneDelegate.swift index b3b2924..63ea4a1 100644 --- a/Example/Source/SceneDelegate.swift +++ b/Example/Source/SceneDelegate.swift @@ -23,7 +23,9 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { if let windowScene = scene as? UIWindowScene { Columbus.config = CountryPickerConfig() let window = UIWindow(windowScene: windowScene) - window.rootViewController = UIHostingController(rootView: CountryListView()) + let store = CountryStore() + let viewModel = CountryListViewModel(store: store) + window.rootViewController = UIHostingController(rootView: CountryListView(store: store).environmentObject(viewModel)) self.window = window window.makeKeyAndVisible() } @@ -59,7 +61,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { } public struct CountryPickerConfig: Configuration { - public var shadowRadius: CGFloat = 10.0 + public var shadowRadius: CGFloat = 5.0 public var textAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 15)] public var lineWidth: CGFloat = 1.0 / UIScreen.main.scale public var rasterSize: CGFloat = 12.0 diff --git a/Source/Classes/Configuration.swift b/Source/Classes/Configuration.swift index 28283a7..4f2dd76 100644 --- a/Source/Classes/Configuration.swift +++ b/Source/Classes/Configuration.swift @@ -19,7 +19,7 @@ public protocol Configuration { } public struct DefaultConfig: Configuration { - public var shadowRadius: CGFloat = 10.0 + public var shadowRadius: CGFloat = 5.0 public var textAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 20)] public var lineWidth: CGFloat = 1.0 / UIScreen.main.scale public var rasterSize: CGFloat = 12.0 diff --git a/Source/Classes/CountryListView.swift b/Source/Classes/CountryListView.swift index d1842ef..a2dc54c 100644 --- a/Source/Classes/CountryListView.swift +++ b/Source/Classes/CountryListView.swift @@ -9,40 +9,47 @@ import SwiftUI public struct CountryListView: View { - @ObservedObject private var store = CountryStore() - @State var query: String = "" + @ObservedObject private var store: CountryStore + @EnvironmentObject var viewModel: CountryListViewModel private let raster = Columbus.config.rasterSize - public init() { + public init(store: CountryStore) { + self.store = store } - #warning("Implement a search bar!") - #warning("Implement an index bar!") - #warning("Find out how to elegate country to the outside world") + #warning("Initially show button to present/dismiss Columbus") + #warning("Find out how to delegate country to the outside world - Environment!") #warning("Consider distributing the framework as Binary Package so it is not compiled all the time.") #warning("Update README.md for SPM/Binary Package and usage instructions.") + #warning("Implement an index bar!") public var body: some View { - NavigationView { + + return NavigationView { VStack(spacing: raster) { HStack(spacing: raster) { Image(systemName: "magnifyingglass") - TextField(Columbus.config.searchBarPlaceholder, text: $query) { - self.store.filter(query: self.query) - } - .textFieldStyle(RoundedBorderTextFieldStyle()) - .foregroundColor(Color(.text)) + TextField(Columbus.config.searchBarPlaceholder, text: $viewModel.query) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .foregroundColor(Color(.text)) } .padding() - List(store.filteredCountries) { country in - CountryRow(country: country) + Text("SelectedCountry: \(self.viewModel.selectedCountry?.name ?? "none")") + + List(self.viewModel.filteredCountries) { country in + ZStack { + CountryRow(country: country) + Button("", action: { + self.viewModel.selectedCountry = country + }) + } } } .navigationBarTitle(Text("Countries")) }.onAppear { - self.store.load() + self.viewModel.filteredCountries = self.store.countries } } } @@ -50,7 +57,7 @@ public struct CountryListView: View { #if DEBUG struct CountryListView_Previews: PreviewProvider { static var previews: some View { - CountryListView() + CountryListView(store: CountryStore()) } } #endif diff --git a/Source/Classes/CountryListViewModel.swift b/Source/Classes/CountryListViewModel.swift new file mode 100644 index 0000000..7668b4c --- /dev/null +++ b/Source/Classes/CountryListViewModel.swift @@ -0,0 +1,71 @@ +// +// CountryListViewModel.swift +// Columbus-iOS +// +// Created by Stefan Herold on 01.08.19. +// Copyright © 2019 CodingCobra. All rights reserved. +// + +import SwiftUI +import Combine + +/// Advances in Networking 1: https://developer.apple.com/videos/play/wwdc2019/712/?time=705 +/// https://www.reddit.com/r/SwiftUI/comments/c5wi5w/throttledebounce_binding/ +/// https://github.com/Dimillian/MovieSwiftUI/blob/7ed177d18f83406c80ca7367a2e2fb3f73b9f156/MovieSwift/MovieSwift/binding/SearchTextBinding.swift +public final class CountryListViewModel: ObservableObject { + + private var store: CountryStore + + @Published var filteredCountries: [Country] = [] + @Published var selectedCountry: Country? = nil + var query: String = "" { + willSet { + DispatchQueue.main.async { + self.searchSubject.send(newValue) + } + } + didSet { + DispatchQueue.main.async { + self.onUpdateText(text: self.query) + } + } + } + + + private let searchSubject = PassthroughSubject() + + private var searchCancellable: Cancellable? { + didSet { + oldValue?.cancel() + } + } + + deinit { + searchCancellable?.cancel() + } + + public init(store: CountryStore) { + self.store = store + + searchCancellable = searchSubject + .eraseToAnyPublisher().map { $0 } + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) +// .throttle(for: 0.3, scheduler: RunLoop.main, latest: true) + .removeDuplicates() +// .filter { !$0.isEmpty } + .sink(receiveValue: { (searchText) in + self.filteredCountries = self.store.filtered(by: searchText) + self.onUpdateTextDebounced(text: searchText) + }) + } + + + /// Overwrite by your subclass to get instant text update. + func onUpdateText(text: String) { + + } + + /// Overwrite by your subclass to get debounced text update. + func onUpdateTextDebounced(text: String) { + } +} diff --git a/Source/Classes/CountryStore.swift b/Source/Classes/CountryStore.swift index 19e288e..b787ddd 100644 --- a/Source/Classes/CountryStore.swift +++ b/Source/Classes/CountryStore.swift @@ -9,14 +9,18 @@ import SwiftUI import Combine -final class CountryStore: ObservableObject { +public final class CountryStore: ObservableObject { @Published var countries: [Country] = [] - @Published var filteredCountries: [Country] = [] - init() {} - - func filter(query: String) { + public init() { + load() + } + + func filtered(by query: String) -> [Country] { + guard !query.isEmpty else { + return countries + } let filteredByName = self.countries.filter { $0.name.lowercased().contains(query.lowercased()) } @@ -24,11 +28,7 @@ final class CountryStore: ObservableObject { "+\($0.dialingCode)".contains(query) } - if !filteredByName.isEmpty { - self.filteredCountries = filteredByName - } else { - self.filteredCountries = filteredByDialingCode - } + return filteredByName.isEmpty ? filteredByDialingCode : filteredByName } func load() { @@ -39,7 +39,6 @@ final class CountryStore: ObservableObject { return } self.countries = countries - self.filteredCountries = countries } }