架构之路 (六) —— VIPER架构模式(二)
2020-04-27 本文已影响0人
刀客传奇
版本记录
版本号 | 时间 |
---|---|
V1.0 | 2020.04.27 星期一 |
前言
前面写了那么多篇主要着眼于局部问题的解决,包括特定功能的实现、通用工具类的封装、视频和语音多媒体的底层和实现以及动画酷炫的实现方式等等。接下来这几篇我们就一起看一下关于iOS系统架构以及独立做一个APP的架构设计的相关问题。感兴趣的可以看上面几篇。
1. 架构之路 (一) —— iOS原生系统架构(一)
2. 架构之路 (二) —— APP架构分析(一)
3. 架构之路 (三) —— APP架构之网络层分析(一)
4. 架构之路 (四) —— APP架构之工程实践中网络层的搭建(二)
5. 架构之路 (五) —— VIPER架构模式(一)
源码
1. Swift
首先看下工程组织结构
下面就是源码了
1. SceneDelegate.swift
import SwiftUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
let model = DataModel()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
model.load()
let contentView = ContentView()
.environmentObject(model)
// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
}
2. ContentView.swift
import SwiftUI
struct ContentView: View {
@EnvironmentObject var model: DataModel
var body: some View {
NavigationView {
TripListView(presenter:
TripListPresenter(interactor:
TripListInteractor(model: model)))
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let model = DataModel.sample
return ContentView()
.environmentObject(model)
}
}
#endif
3. TripListInteractor.swift
import Foundation
class TripListInteractor {
let model: DataModel
init (model: DataModel) {
self.model = model
}
func addNewTrip() {
model.pushNewTrip()
}
func deleteTrip(_ index: IndexSet) {
model.trips.remove(atOffsets: index)
}
}
4. TripListPresenter.swift
import SwiftUI
import Combine
class TripListPresenter: ObservableObject {
private let interactor: TripListInteractor
private let router = TripListRouter()
private var cancellables = Set<AnyCancellable>()
@Published var trips: [Trip] = []
init(interactor: TripListInteractor) {
self.interactor = interactor
interactor.model.$trips
.assign(to: \.trips, on: self)
.store(in: &cancellables)
}
func makeAddNewButton() -> some View {
Button(action: addNewTrip) {
Image(systemName: "plus")
}
}
func addNewTrip() {
interactor.addNewTrip()
}
func deleteTrip(_ index: IndexSet) {
interactor.deleteTrip(index)
}
func linkBuilder<Content: View>(for trip: Trip, @ViewBuilder content: () -> Content
) -> some View {
NavigationLink(destination: router.makeDetailView(for: trip, model: interactor.model)) {
content()
}
}
}
5. TripListView.swift
import SwiftUI
struct TripListView: View {
@ObservedObject var presenter: TripListPresenter
var body: some View {
List {
ForEach (presenter.trips, id: \.id) { item in
self.presenter.linkBuilder(for: item) {
TripListCell(trip: item)
.frame(height: 240)
}
}
.onDelete(perform: presenter.deleteTrip)
}
.navigationBarTitle("Roadtrips", displayMode: .inline)
.navigationBarItems(trailing: presenter.makeAddNewButton())
}
}
struct TripListView_Previews: PreviewProvider {
static var previews: some View {
let model = DataModel.sample
let interactor = TripListInteractor(model: model)
let presenter = TripListPresenter(interactor: interactor)
return NavigationView {
TripListView(presenter: presenter)
}
}
}
6. TripListRouter.swift
import SwiftUI
class TripListRouter {
func makeDetailView(for trip: Trip, model: DataModel) -> some View {
let presenter = TripDetailPresenter(interactor:
TripDetailInteractor(
trip: trip,
model: model,
mapInfoProvider: RealMapDataProvider()))
return TripDetailView(presenter: presenter)
}
}
7. TripDetailInteractor.swift
import Combine
import MapKit
class TripDetailInteractor {
private let trip: Trip
private let model: DataModel
let mapInfoProvider: MapDataProvider
private var cancellables = Set<AnyCancellable>()
var tripName: String { trip.name }
var tripNamePublisher: Published<String>.Publisher { trip.$name }
@Published var totalDistance: Measurement<UnitLength> = Measurement(value: 0, unit: .meters)
@Published var waypoints: [Waypoint] = []
@Published var directions: [MKRoute] = []
init (trip: Trip, model: DataModel, mapInfoProvider: MapDataProvider) {
self.trip = trip
self.mapInfoProvider = mapInfoProvider
self.model = model
trip.$waypoints
.assign(to: \.waypoints, on: self)
.store(in: &cancellables)
trip.$waypoints
.flatMap { mapInfoProvider.totalDistance(for: $0) }
.map { Measurement(value: $0, unit: UnitLength.meters) }
.assign(to: \.totalDistance, on: self)
.store(in: &cancellables)
trip.$waypoints
.setFailureType(to: Error.self)
.flatMap { mapInfoProvider.directions(for: $0) }
.catch { _ in Empty<[MKRoute], Never>()}
.assign(to: \.directions, on: self)
.store(in: &cancellables)
}
func setTripName(_ name: String) {
trip.name = name
}
func save() {
model.save()
}
// MARK: - Waypoints
func addWaypoint() {
trip.addWaypoint()
}
func moveWaypoint(fromOffsets: IndexSet, toOffset: Int) {
trip.waypoints.move(fromOffsets: fromOffsets, toOffset: toOffset)
}
func deleteWaypoint(atOffsets: IndexSet) {
trip.waypoints.remove(atOffsets: atOffsets)
}
func updateWaypoints() {
trip.waypoints = trip.waypoints
}
}
8. TripDetailPresenter.swift
import SwiftUI
import Combine
class TripDetailPresenter: ObservableObject {
private let interactor: TripDetailInteractor
private let router: TripDetailRouter
private var cancellables = Set<AnyCancellable>()
@Published var tripName: String = "No name"
let setTripName: Binding<String>
@Published var distanceLabel: String = "Calculating..."
@Published var waypoints: [Waypoint] = []
init(interactor: TripDetailInteractor) {
self.interactor = interactor
self.router = TripDetailRouter(mapProvider: interactor.mapInfoProvider)
// 1
setTripName = Binding<String>(
get: { interactor.tripName },
set: { interactor.setTripName($0) }
)
// 2
interactor.tripNamePublisher
.assign(to: \.tripName, on: self)
.store(in: &cancellables)
interactor.$totalDistance
.map { "Total Distance: " + MeasurementFormatter().string(from: $0) }
.replaceNil(with: "Calculating...")
.assign(to: \.distanceLabel, on: self)
.store(in: &cancellables)
interactor.$waypoints
.assign(to: \.waypoints, on: self)
.store(in: &cancellables)
}
func save() {
interactor.save()
}
func makeMapView() -> some View {
TripMapView(presenter: TripMapViewPresenter(interactor: interactor))
}
// MARK: - Waypoints
func addWaypoint() {
interactor.addWaypoint()
}
func didMoveWaypoint(fromOffsets: IndexSet, toOffset: Int) {
interactor.moveWaypoint(fromOffsets: fromOffsets, toOffset: toOffset)
}
func didDeleteWaypoint(_ atOffsets: IndexSet) {
interactor.deleteWaypoint(atOffsets: atOffsets)
}
func cell(for waypoint: Waypoint) -> some View {
let destination = router.makeWaypointView(for: waypoint)
.onDisappear(perform: interactor.updateWaypoints)
return NavigationLink(destination: destination) {
Text(waypoint.name)
}
}
}
9. TripDetailView.swift
import SwiftUI
struct TripDetailView: View {
@ObservedObject var presenter: TripDetailPresenter
var body: some View {
VStack {
TextField("Trip Name", text: presenter.setTripName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding([.horizontal])
presenter.makeMapView()
Text(presenter.distanceLabel)
HStack {
Spacer()
EditButton()
Button(action: presenter.addWaypoint) {
Text("Add")
}
}.padding([.horizontal])
List {
ForEach(presenter.waypoints, content: presenter.cell)
.onMove(perform: presenter.didMoveWaypoint(fromOffsets:toOffset:))
.onDelete(perform: presenter.didDeleteWaypoint(_:))
}
}
.navigationBarTitle(Text(presenter.tripName), displayMode: .inline)
.navigationBarItems(trailing: Button("Save", action: presenter.save))
}
}
struct TripDetailView_Previews: PreviewProvider {
static var previews: some View {
let model = DataModel.sample
let trip = model.trips[1]
let mapProvider = RealMapDataProvider()
let presenter = TripDetailPresenter(interactor:
TripDetailInteractor(
trip: trip,
model: model,
mapInfoProvider: mapProvider))
return NavigationView {
TripDetailView(presenter: presenter)
}
}
}
10. TripMapViewPresenter.swift
import MapKit
import Combine
class TripMapViewPresenter: ObservableObject {
@Published var pins: [MKAnnotation] = []
@Published var routes: [MKRoute] = []
let interactor: TripDetailInteractor
private var cancellables = Set<AnyCancellable>()
init(interactor: TripDetailInteractor) {
self.interactor = interactor
interactor.$waypoints
.map {
$0.map {
let annotation = MKPointAnnotation()
annotation.coordinate = $0.location
return annotation
}
}
.assign(to: \.pins, on: self)
.store(in: &cancellables)
interactor.$directions
.assign(to: \.routes, on: self)
.store(in: &cancellables)
}
}
11. TripMapView.swift
import SwiftUI
struct TripMapView: View {
@ObservedObject var presenter: TripMapViewPresenter
var body: some View {
MapView(pins: presenter.pins, routes: presenter.routes)
}
}
#if DEBUG
struct TripMapView_Previews: PreviewProvider {
static var previews: some View {
let model = DataModel.sample
let trip = model.trips[0]
let interactor = TripDetailInteractor(
trip: trip,
model: model,
mapInfoProvider: RealMapDataProvider())
let presenter = TripMapViewPresenter(interactor: interactor)
return VStack {
TripMapView(presenter: presenter)
}
}
}
#endif
12. TripDetailRouter.swift
import SwiftUI
class TripDetailRouter {
private let mapProvider: MapDataProvider
init(mapProvider: MapDataProvider) {
self.mapProvider = mapProvider
}
func makeWaypointView(for waypoint: Waypoint) -> some View {
let presenter = WaypointViewPresenter(
waypoint: waypoint,
interactor: WaypointViewInteractor(
waypoint: waypoint,
mapInfoProvider: mapProvider))
return WaypointView(presenter: presenter)
}
}
13. MapView.swift
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
var pins: [MKAnnotation] = []
var routes: [MKRoute]?
var center: CLLocationCoordinate2D?
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
view.removeAnnotations(view.annotations)
view.removeOverlays(view.overlays)
if let center = center {
view.setRegion(MKCoordinateRegion(center: center, latitudinalMeters: 2000, longitudinalMeters: 2000), animated: true)
view.addAnnotation( {
let annotation = MKPointAnnotation()
annotation.coordinate = center
return annotation
}())
}
if pins.count > 0 {
view.addAnnotations(pins)
view.showAnnotations(pins, animated: false)
}
if let routes = routes {
routes.forEach { route in
view.addOverlay(route.polyline, level: .aboveRoads)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
guard let polyline = overlay as? MKPolyline else {
return MKOverlayRenderer(overlay: overlay)
}
let lineRenderer = MKPolylineRenderer(polyline: polyline)
lineRenderer.strokeColor = .blue
lineRenderer.lineWidth = 3
return lineRenderer
}
}
}
fileprivate class CoordinateWrapper: NSObject, MKAnnotation {
var coordinate: CLLocationCoordinate2D
init(_ coordinate: CLLocationCoordinate2D) {
self.coordinate = coordinate
}
}
#if DEBUG
struct MapView_Previews: PreviewProvider {
static var previews: some View {
let pins = DataModel.sample.trips[0].waypoints.map { waypoint -> MKPointAnnotation in
let annotation = MKPointAnnotation()
annotation.coordinate = waypoint.location
return annotation
}
return Group {
MapView(pins: pins, routes: nil, center: nil)
.previewDisplayName("Pins")
MapView(pins: [], routes: nil, center: CLLocationCoordinate2D.timesSquare)
.previewDisplayName("Centered")
}
}
}
#endif
14. SplitImage.swift
import SwiftUI
struct SplitImage: View {
var images: [UIImage]
func defaultImageView() -> some View {
Image("no_waypoints")
.resizable()
.aspectRatio(contentMode: .fill)
}
func image(for uiImage: UIImage) -> some View {
return Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
}
func oneImageView() -> some View {
image(for: images[0])
}
func twoImagesView() -> some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
self.image(for: self.images[0])
.frame(width: geometry.size.width)
.clipShape(TopTriangle(offset: 4))
self.image(for: self.images[1])
.frame(width: geometry.size.width)
.clipShape(BottomTriangle(offset: 4))
}
}
}
var body: some View {
if images.count == 0 {
return AnyView(defaultImageView())
}
if images.count == 1 {
return AnyView(oneImageView())
}
return AnyView(twoImagesView())
}
}
struct TopTriangle: Shape {
var offset: CGFloat = 2
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - offset, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - offset))
path.closeSubpath()
return path
}
}
struct BottomTriangle: Shape {
var offset: CGFloat = 2
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + offset, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + offset))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.closeSubpath()
return path
}
}
#if DEBUG
struct SplitImage_Previews: PreviewProvider {
static var previews: some View {
Group {
SplitImage(images: [])
.frame(height: 200)
SplitImage(images: [UIImage(named: "waypoint.0")!])
.frame(height: 100)
SplitImage(images: [UIImage(named: "waypoint.1")!])
.frame(height: 100)
SplitImage(images: [UIImage(named: "waypoint.0")!, UIImage(named: "waypoint.1")!])
.frame(height: 100)
}
}
}
#endif
15. TripListCell.swift
import SwiftUI
import Combine
struct TripListCell: View {
let imageProvider: ImageDataProvider = PixabayImageDataProvider() // this could be injected in the future
@ObservedObject var trip: Trip
@State private var images: [UIImage] = []
@State private var cancellable: AnyCancellable?
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .bottomLeading) {
SplitImage(images: self.images)
.frame(width: geometry.size.width, height: geometry.size.height)
BlurView()
.frame(width: geometry.size.width, height: 42)
Text(self.trip.name)
.font(.system(size: 32))
.fontWeight(.bold)
.foregroundColor(.white)
.padding(EdgeInsets(top: 0, leading: 8, bottom: 4, trailing: 8))
}
.cornerRadius(12)
}.onAppear() {
self.cancellable = self.imageProvider.getEndImages(for: self.trip).assign(to: \.images, on: self)
}
}
}
#if DEBUG
struct TripListCell_Previews: PreviewProvider {
static var previews: some View {
let model = DataModel.sample
let trip = model.trips[0]
return TripListCell(trip: trip)
.frame(height: 160)
.environmentObject(model)
}
}
#endif
struct BlurView: UIViewRepresentable {
func makeUIView(context: UIViewRepresentableContext<BlurView>) -> UIView {
let view = UIView()
view.backgroundColor = .clear
let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial)
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.translatesAutoresizingMaskIntoConstraints = false
view.insertSubview(blurView, at: 0)
NSLayoutConstraint.activate([
blurView.heightAnchor.constraint(equalTo: view.heightAnchor),
blurView.widthAnchor.constraint(equalTo: view.widthAnchor),
])
return view
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<BlurView>) {
}
}
16. Trip.swift
import Foundation
import Combine
final class Trip {
@Published var name: String = ""
@Published var waypoints: [Waypoint] = []
let id: UUID
init() {
id = UUID()
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
waypoints = try container.decode([Waypoint].self, forKey: .waypoints)
id = try container.decode(UUID.self, forKey: .id)
}
func addWaypoint() {
let waypoint = waypoints.last?.copy() ?? Waypoint()
waypoint.name = "New Stop"
waypoints.append(waypoint)
}
}
extension Trip: Codable {
enum CodingKeys: CodingKey {
case name
case waypoints
case id
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(waypoints, forKey: .waypoints)
try container.encode(id, forKey: .id)
}
}
extension Trip: Equatable {
static func == (lhs: Trip, rhs: Trip) -> Bool {
lhs.id == rhs.id
}
}
extension Trip: Identifiable {}
extension Trip: ObservableObject {}
17. Waypoint.swift
import Combine
import CoreLocation
import MapKit
final class Waypoint {
@Published var name: String
@Published var location: CLLocationCoordinate2D
var id: UUID
init() {
id = UUID()
name = "Times Square"
location = CLLocationCoordinate2D.timesSquare
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
location = try container.decode(CLLocationCoordinate2D.self, forKey: .location)
id = try container.decode(UUID.self, forKey: .id)
}
func copy() -> Waypoint {
let new = Waypoint()
new.name = name
new.location = location
return new
}
}
extension Waypoint: Equatable {
static func == (lhs: Waypoint, rhs: Waypoint) -> Bool {
return lhs.id == rhs.id
}
}
extension Waypoint: CustomStringConvertible {
var description: String { name }
}
extension Waypoint: Codable {
enum CodingKeys: CodingKey {
case name
case location
case id
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(location, forKey: .location)
try container.encode(id, forKey: .id)
}
}
extension Waypoint: Identifiable {}
extension Waypoint {
var mapItem: MKMapItem {
return MKMapItem(placemark: MKPlacemark(coordinate: location))
}
}
extension CLLocationCoordinate2D: Codable {
public init(from decoder: Decoder) throws {
let representation = try decoder.singleValueContainer().decode([String: CLLocationDegrees].self)
self.init(latitude: representation["latitude"] ?? 0, longitude: representation["longitude"] ?? 0)
}
public func encode(to encoder: Encoder) throws {
let representation = ["latitude": self.latitude, "longitude": self.longitude]
try representation.encode(to: encoder)
}
}
18. DataModel.swift
import Combine
final class DataModel {
private let persistence = Persistence()
@Published var trips: [Trip] = []
private var cancellables = Set<AnyCancellable>()
func load() {
persistence.load()
.assign(to: \.trips, on: self)
.store(in: &cancellables)
}
func save() {
persistence.save(trips: trips)
}
func loadDefault(synchronous: Bool = false) {
persistence.loadDefault(synchronous: synchronous)
.assign(to: \.trips, on: self)
.store(in: &cancellables)
}
func pushNewTrip() {
let new = Trip()
new.name = "New Trip"
trips.insert(new, at: 0)
}
func removeTrip(trip: Trip) {
trips.removeAll { $0.id == trip.id }
}
}
extension DataModel: ObservableObject {}
/// Extension for SwiftUI previews
#if DEBUG
extension DataModel {
static var sample: DataModel {
let model = DataModel()
model.loadDefault(synchronous: true)
return model
}
}
#endif
19. Persistence.swift
import Foundation
import Combine
fileprivate struct Envelope: Codable {
let trips: [Trip]
}
/// This class can be refactored to save/load over a network instead of a local file
class Persistence {
var localFile: URL {
let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("trips.json")
print("In case you need to delete the database: \(fileURL)")
return fileURL
}
var defaultFile: URL {
return Bundle.main.url(forResource: "default", withExtension: "json")!
}
private func clear() {
try? FileManager.default.removeItem(at: localFile)
}
func load() -> AnyPublisher<[Trip], Never> {
if FileManager.default.fileExists(atPath: localFile.standardizedFileURL.path) {
return Future<[Trip], Never> { promise in
self.load(self.localFile) { trips in
DispatchQueue.main.async {
promise(.success(trips))
}
}
}.eraseToAnyPublisher()
} else {
return loadDefault()
}
}
func save(trips: [Trip]) {
let envelope = Envelope(trips: trips)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try! encoder.encode(envelope)
try! data.write(to: localFile)
}
private func loadSynchronously(_ file: URL) -> [Trip] {
do {
let data = try Data(contentsOf: file)
let envelope = try JSONDecoder().decode(Envelope.self, from: data)
return envelope.trips
} catch {
clear()
return loadSynchronously(defaultFile)
}
}
private func load(_ file: URL, completion: @escaping ([Trip]) -> Void) {
DispatchQueue.global(qos: .background).async {
let trips = self.loadSynchronously(file)
completion(trips)
}
}
func loadDefault(synchronous: Bool = false) -> AnyPublisher<[Trip], Never> {
if synchronous {
return Just<[Trip]>(loadSynchronously(defaultFile)).eraseToAnyPublisher()
}
return Future<[Trip], Never> { promise in
self.load(self.defaultFile) { trips in
DispatchQueue.main.async {
promise(.success(trips))
}
}
}.eraseToAnyPublisher()
}
}
20. MapDataProvider.swift
import Foundation
import Combine
import MapKit
import CoreLocation
protocol MapDataProvider {
func getLocation(for address:String) -> AnyPublisher<CLPlacemark, Error>
func directions(for waypoints:[Waypoint]) -> AnyPublisher<[MKRoute], Error>
func totalDistance(for trip: [Waypoint]) -> AnyPublisher<Double, Never>
}
enum CustomErrors: String, Error {
case unknown
case noData
}
class RealMapDataProvider: MapDataProvider {
let geocoder = CLGeocoder()
func getLocation(for address:String) -> AnyPublisher<CLPlacemark, Error> {
let subject = PassthroughSubject< CLPlacemark, Error>()
geocoder.geocodeAddressString(address) { placemarks, error in
if let placemark = placemarks?.first {
subject.send(placemark)
subject.send(completion: .finished)
} else if let error = error {
subject.send(completion: .failure(error))
} else {
subject.send(completion: .failure(CustomErrors.unknown))
}
}
return subject
.eraseToAnyPublisher()
}
func directions(for waypoints:[Waypoint]) -> AnyPublisher<[MKRoute], Error> {
guard waypoints.count > 1 else {
return Empty<[MKRoute], Error>().eraseToAnyPublisher()
}
var routePublishers: [AnyPublisher<[MKRoute], Error>] = []
(0 ..< waypoints.count - 1).forEach { index in
let start = waypoints[index]
let end = waypoints[index + 1]
let request = MKDirections.Request()
request.transportType = .automobile
request.source = start.mapItem
request.destination = end.mapItem
let directions = MKDirections(request: request)
routePublishers.append(directions.calculate())
}
let allPublisher = Publishers.Sequence<[AnyPublisher<[MKRoute], Error>], Error>(sequence: routePublishers)
return allPublisher.flatMap { $0 }
.collect()
.map { $0.compactMap { $0.first }} // get just the first route and make a list
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func totalDistance(for trip: [Waypoint]) -> AnyPublisher<Double, Never> {
return directions(for: trip)
.replaceError(with: [])
.map { routes in
routes.map { route in
route.distance
}.reduce(0, +)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
extension MKDirections {
func calculate() -> AnyPublisher<[MKRoute], Error> {
let subject = PassthroughSubject<[MKRoute], Error>()
calculate { response, error in
if let routes = response?.routes {
subject.send(routes)
subject.send(completion: .finished)
} else if let error = error {
subject.send(completion: .failure(error))
} else {
subject.send(completion: .finished)
}
}
return subject.eraseToAnyPublisher()
}
}
extension CLLocationCoordinate2D {
static var timesSquare: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: 40.757, longitude: -73.986)}
}
21. ImageDataProvider.swift
import UIKit
import Combine
protocol ImageDataProvider {
func getEndImages(for trip: Trip) -> AnyPublisher<[UIImage], Never>
}
private struct PixabayResponse: Codable {
struct Image: Codable {
let largeImageURL: String
let user: String
}
let hits: [Image]
}
//Get an API Key here: https://pixabay.com/accounts/register/
class PixabayImageDataProvider: ImageDataProvider {
let apiKey = "<#Enter your API key here#>"
private func searchURL(query: String) -> URL {
var components = URLComponents(string: "https://pixabay.com/api")!
components.queryItems = [
URLQueryItem(name: "key", value: apiKey),
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "image_type", value: "photo")
]
return components.url!
}
private func imageForQuery(query: String) -> AnyPublisher<UIImage, Never> {
URLSession.shared.dataTaskPublisher(for: searchURL(query: query))
.map { $0.data }
.decode(type: PixabayResponse.self, decoder: JSONDecoder())
.tryMap { response -> URL in
guard
let urlString = response.hits.first?.largeImageURL,
let url = URL(string: urlString)
else {
throw CustomErrors.noData
}
return url
}.catch { _ in Empty<URL, URLError>() }
.flatMap { URLSession.shared.dataTaskPublisher(for: $0) }
.map { $0.data }
.tryMap { imageData in
guard let image = UIImage(data: imageData) else { throw CustomErrors.noData }
return image
}.catch { _ in Empty<UIImage, Never>()}
.eraseToAnyPublisher()
}
func getEndImages(for trip: Trip) -> AnyPublisher<[UIImage], Never> {
if trip.waypoints.count == 0 {
return Empty<[UIImage], Never>()
.eraseToAnyPublisher()
}
if trip.waypoints.count == 1 {
return imageForQuery(query: trip.waypoints[0].name)
.map { [$0] }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
let start = imageForQuery(query: trip.waypoints[0].name)
let end = imageForQuery(query: trip.waypoints.last!.name)
return Publishers.Merge(start, end)
.collect()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
22. WaypointViewPresenter.swift
import Combine
import SwiftUI
import CoreLocation
class WaypointViewPresenter: ObservableObject {
@Published var query: String = ""
@Published var info: String = "No results"
@Published var name: String = "unknown"
@Published var location: CLLocationCoordinate2D
@Published var isValid: Bool = false
private var cancellables = Set<AnyCancellable>()
private let interactor: WaypointViewInteractor
private func formatInfo(_ placemark: CLPlacemark) -> String {
var info = placemark.name ?? "unknown"
if let city = placemark.locality {
info += ", \(city)"
}
if let state = placemark.administrativeArea {
info += ", \(state)"
}
return info
}
init(waypoint: Waypoint, interactor: WaypointViewInteractor) {
self.interactor = interactor
location = waypoint.location
query = waypoint.name
$query
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.sink(receiveValue: handleQuery)
.store(in: &cancellables)
}
private func handleQuery(_ query: String) {
let suggestion = interactor.getLocation(for: query)
suggestion
.map { self.formatInfo($0) }
.catch { _ in Empty<String, Never>() }
.assign(to: \.info, on: self)
.store(in: &cancellables)
suggestion
.map { $0.name }
.replaceNil(with: "unknown")
.catch { _ in Empty<String, Never>() }
.assign(to: \.name, on: self)
.store(in: &cancellables)
suggestion
.map { $0.location }
.replaceNil(with: CLLocation(latitude: 0, longitude: 0))
.catch { _ in Empty<CLLocation, Never>() }
.map { $0.coordinate }
.assign(to: \.location, on: self)
.store(in: &cancellables)
suggestion
.map { _ in true }
.catch {_ in Just<Bool>(false) }
.assign(to: \.isValid, on: self)
.store(in: &cancellables)
}
func didTapUseThis() {
interactor.apply(name: name, location: location)
}
}
23. WaypointView.swift
import SwiftUI
import Combine
import CoreLocation
import MapKit
struct WaypointView: View {
@EnvironmentObject var model: DataModel
@Environment(\.presentationMode) var mode
@ObservedObject var presenter: WaypointViewPresenter
init(presenter: WaypointViewPresenter) {
self.presenter = presenter
}
func applySuggestion() {
presenter.didTapUseThis()
mode.wrappedValue.dismiss()
}
var body: some View {
return
VStack{
VStack {
TextField("Type an Address", text: $presenter.query)
.textFieldStyle(RoundedBorderTextFieldStyle())
HStack {
Text(presenter.info)
Spacer()
Button(action: applySuggestion) {
Text("Use this")
}.disabled(!presenter.isValid)
}
}.padding([.horizontal])
MapView(center: presenter.location)
}.navigationBarTitle(Text(""), displayMode: .inline)
}
}
#if DEBUG
struct WaypointView_Previews: PreviewProvider {
static var previews: some View {
let model = DataModel.sample
let waypoint = model.trips[0].waypoints[0]
let provider = RealMapDataProvider()
return
Group {
NavigationView {
WaypointView(presenter: WaypointViewPresenter(waypoint: waypoint, interactor: WaypointViewInteractor(waypoint: waypoint, mapInfoProvider: provider)))
.environmentObject(model)
}.previewDisplayName("Detail")
NavigationView {
WaypointView(presenter: WaypointViewPresenter(waypoint: Waypoint(), interactor: WaypointViewInteractor(waypoint: Waypoint(), mapInfoProvider: provider)))
.environmentObject(model)
.previewDisplayName("New")
}
}
}
}
#endif
24. WaypointViewInteractor.swift
import Foundation
import Combine
import CoreLocation
class WaypointViewInteractor {
private let waypoint: Waypoint
private let mapInfoProvider: MapDataProvider
init(waypoint: Waypoint, mapInfoProvider: MapDataProvider) {
self.waypoint = waypoint
self.mapInfoProvider = mapInfoProvider
}
func getLocation(for address:String) -> AnyPublisher<CLPlacemark, Error> {
mapInfoProvider.getLocation(for: address)
}
func apply(name: String, location: CLLocationCoordinate2D) {
waypoint.name = name
waypoint.location = location
}
}
后记
本篇主要介绍了VIPER架构模式,感兴趣的给个赞或者关注~~~