Нативная SwiftUI-навигация с NavigationPath

Изначально возможности стэк-навигации в SwiftUI были очень скромными. Перейти на другой экран в рамках NavigationStack можно было лишь через создание NavigationLink во вьюхе, что жестко связывало с ней роутинг.

С выходом iOS 16 разработчикам предоставили более гибкий способ, с использованием нового объекта NavigationPath. Он представляет собой список любых Hashable-значений, и определяет актуальный стэк навигации. Чтобы показать следующий экран, требуется добавить в него значение, чтобы вернуться назад — удалить.

При добавлении нового значения, у корневой вьюхи NavigationStack вызывается модификатор navigationDestination, который должен обработать его, и вернуть необходимое для отображения представление.

struct ContentView: View {
    private let columns = [
        GridItem(.flexible()),
        GridItem(.flexible())
    ]

    private let colors: [Color] = [
        .red,.gray,
        .green, .yellow,
        .blue, .purple,
        .indigo, .orange
    ]

    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            ScrollView {
                LazyVGrid(columns: columns) {
                    ForEach(colors, id: \.self) { color in
                        color
                            .aspectRatio(1, contentMode: .fill)
                            .cornerRadius(8)
                            .onTapGesture {
                                navigationPath.append(color)
                            }
                    }
                }
                .padding()
            }
            .navigationBarTitleDisplayMode(.inline)
            .navigationTitle("Colors")
            .navigationDestination(for: Color.self) { color in
                color
                    .edgesIgnoringSafeArea(.bottom)
                    .navigationBarTitleDisplayMode(.inline)
                    .navigationTitle("Color")
            }
        }
    }
}

При нажатии на цвет открывается новый экран с ним.

Colors screen Color screen

Однако и в таком виде роутинг сильно связан с вьюхами. Поэтому займемся унификацией и выносом логики вовне.

Для начала определим общий протокол роутеров.

protocol Router: AnyObject {
    associatedtype Route: Hashable
    associatedtype View: SwiftUI.View

    var path: NavigationPath { get set }

    func makeView(for route: Route) -> Self.View
    func route(to route: Route)
}

extension Router {
    func route(to route: Route) {
        path.append(route)
    }
}

И создадим модификатор routing, через который будем задавать вьюхам соответствующий роутер.


struct RoutingViewModifier<ConcreteRouter: Router>: ViewModifier {
    private let router: ConcreteRouter

    init(with router: ConcreteRouter) {
        self.router = router
    }

    func body(content: Content) -> some View {
        content
            .navigationDestination(for: ConcreteRouter.Route.self) { route in
                router.makeView(for: route)
            }
    }
}

extension View {
    func routing(with router: some Router) -> some View {
        modifier(RoutingViewModifier(with: router))
    }
}

Наконец, сам роутер.

class ColorsRouter: Router, ObservableObject {
    enum Route: Hashable {
        case color(Color)
    }

    @Published var path = NavigationPath()

    func makeView(for route: Route) -> some View {
        switch route {
        case let .color(color):
            return color
                .edgesIgnoringSafeArea(.bottom)
                .navigationBarTitleDisplayMode(.inline)
                .navigationTitle("Color")
        }
    }
}

Связываем все вместе. Теперь роутинг вынесен в свое место, и его расширение не потребует кардинальных изменений во вьхах.

struct ContentView: View {
    private let columns = [
        GridItem(.flexible()),
        GridItem(.flexible())
    ]

    private let colors: [Color] = [
        .red,.gray,
        .green, .yellow,
        .blue, .purple,
        .indigo, .orange
    ]

    @StateObject private var colorsRouter = ColorsRouter()

    var body: some View {
        NavigationStack(path: $colorsRouter.path) {
            ScrollView {
                LazyVGrid(columns: columns) {
                    ForEach(colors, id: \.self) { color in
                        color
                            .aspectRatio(1, contentMode: .fill)
                            .cornerRadius(8)
                            .onTapGesture {
                                colorsRouter.route(to: .color(color))
                            }
                    }
                }
                .padding()
            }
            .routing(with: colorsRouter)
            .navigationBarTitleDisplayMode(.inline)
            .navigationTitle("Colors")
        }
    }
}

Двухколоночный Grid с адаптивными квадратными элементами

Задача: отобразить элементы в две колонки так, чтобы они заняли всю доступную ширину экрана и были квадратными, то есть их ширина равнялась высоте.

С одной стороны все просто — необходимо использовать LazyVGrid. Но с какой конфигурацией? Раз нужна адаптивность по ширине при постоянном количестве колонок, то подойдут два GridItem с flexible-размером:

private let columns = [
    GridItem(.flexible()),
    GridItem(.flexible())
]

В итоге получим следующую картину.

Grid without aspect ratio

Где не хватает условия: высота = ширине. И задать его с помощью настроек Grid нельзя. Но можно через модификатор aspectRatio у самих элементов.

struct ContentView: View {
    private let columns = [
        GridItem(.flexible()),
        GridItem(.flexible())
    ]

    private let colors: [Color] = [
        .red,
        .gray,
        .green,
        .yellow,
        .blue,
        .cyan,
        .black,
        .indigo,
        .mint,
        .orange
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns) {
                ForEach(colors, id: \.self) { color in
                    color
                        .aspectRatio(1, contentMode: .fill)
                }
            }
            .padding()
        }
    }
}

Теперь все выводится как надо.

Grid with aspect ratio

Универсальный веб-сервис

Практически всегда запросы к веб-сервисам имеют однотипную логику, поддающуюся унификации через дженерики. Одним из вариантов такого подхода я бы и хотел поделиться.

Основная логика ляжет в родительский класс Webservice:

import Foundation

class Webservice {
    enum Method {
        case get([URLQueryItem]?)
        case post(Data?)

        var name: String {
            switch self {
            case .get:
                return "GET"

            case .post:
                return "POST"
            }
        }
    }

    struct Header {
        let name: String
        let value: String

        static let jsonContentType = Header(
            name: "Content-Type",
            value: "application/json; charset=UTF-8"
        )
    }

    let baseURL: URL
    let urlSession: URLSession
    let jsonDecoder: JSONDecoder

    init(
        baseURL: URL,
        urlSession: URLSession = .shared,
        jsonDecoder: JSONDecoder = JSONDecoder()
    ) {
        self.baseURL = baseURL
        self.urlSession = urlSession
        self.jsonDecoder = jsonDecoder
    }

    func load<Entity: Codable, Path: CustomStringConvertible>(
        _ type: Entity.Type,
        path: Path,
        method: Method = .get(nil),
        headers: [Header] = [.jsonContentType]
    ) async throws -> Entity {
        var urlRequest: URLRequest

        guard let url = URL(string: path.description, relativeTo: baseURL) else {
            throw URLError(.badURL)
        }

        switch method {
        case .get(let queryItems):
            var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)

            if urlComponents?.queryItems == nil {
                urlComponents?.queryItems = queryItems
            } else {
                urlComponents?.queryItems?.append(contentsOf: queryItems ?? [])
            }

            guard let url = urlComponents?.url else {
                throw URLError(.badURL)
            }

            urlRequest = URLRequest(url: url)

        case .post(let data):
            urlRequest = URLRequest(url: url)
            urlRequest.httpBody = data
        }

        urlRequest.httpMethod = method.name

        for header in headers {
            urlRequest.addValue(header.value, forHTTPHeaderField: header.name)
        }

        let (data, response) = try await urlSession.data(for: urlRequest)

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw URLError(.badServerResponse)
        }

        return try jsonDecoder.decode(Entity.self, from: data)
    }
}

А конкретные методы определяются уже в веб-сервисах, наследующихся от него. В данном случае PhotosWebservice и метод загрузки фотографий:

import Foundation

class PhotoWebservice: Webservice {
    enum Path: CustomStringConvertible {
        case photos
        case user(id: Int)

        var description: String {
            switch self {
            case .photos: return "photos"
            case .user(let id): return "user/\(id)"
            }
        }
    }

    func loadPhotos() async throws -> [Photo] {
        return try await load([Photo].self, path: Path.photos)
    }
}

Изменения в Swift 2.2

Совсем недавно вышел Swift 2.2, внесший ряд изменений в язык.

Наиболее значимые среди них:

С полным списком обновлений можно ознакомиться на официальном сайте языка.

Кортежи

Далеко не все знают, что Swift с самого выхода поддерживает особый вид списочных типов — кортежи. Их отличительной чертой является способность хранить вместе элементы различных типов (ни массивы, ни словари этого не могут).

Обобщая, кортеж представляет собой упорядоченный, фиксированный по длине набор элементов, обращение к которым возможно по порядковым индексам или именам, если они заданы.

import Foundation

typealias Tuple = (id: Int, name: String)

func tupleFromId(id: Int, name: String) -> Tuple {
    return (id, name)
}

let tuple = tupleFromId(1, name: "Swift")

print("Tuple id: \(tuple.id), name: \(tuple.name)")
print("Tuple id: \(tuple.0), name: \(tuple.1)")