Подключение из iOS
Руководство по работе с API из Swift: модели, расчёт цены, дополненная реальность.
Транспорт
Сервер отдаёт валидный сертификат Let's Encrypt, поэтому App Transport Security
доволен и правки в Info.plist не нужны. Никаких исключений вроде
NSAllowsArbitraryLoads добавлять не требуется — а если добавите, App Store
попросит объяснений.
enum API {
static let base = URL(string: "https://api.desperatemeasure.tech")!
static func url(_ path: String) -> URL {
base.appendingPathComponent(path)
}
/// Пути к медиа приходят относительными: /media/elio/1-1200.webp
static func asset(_ relative: String) -> URL {
URL(string: relative, relativeTo: base)!.absoluteURL
}
}
Модели
Схему можно не писать руками — она есть в OpenAPI:
curl https://api.desperatemeasure.tech/openapi.json -o openapi.json
swift-openapi-generator generate openapi.json --mode types
Если генератор подключать не хочется, вот эквивалентные структуры.
Имена полей в API в snake_case, поэтому достаточно поставить
.convertFromSnakeCase и обойтись без CodingKeys.
struct ImageSet: Codable, Hashable {
let thumb: String
let detail: String
let full: String
}
struct Dimensions: Codable, Hashable {
let shape: String?
let diameterMm: Int?
let diameterExtendedMm: Int?
let widthMm: Int?
let widthExtendedMm: Int?
let depthMm: Int?
let heightMm: Int?
let heightOptionsMm: [Int]
let parsed: Bool
}
struct BaseOption: Codable, Hashable, Identifiable {
let id: String
let label: String
let price: Int
let dimensions: Dimensions
/// [идентификатор группы: [идентификатор варианта: надбавка]]
let priceMatrix: [String: [String: Int]]
/// [идентификатор опции: надбавка]
let optionPrices: [String: Int]
}
struct Choice: Codable, Hashable, Identifiable {
let id: String
let label: String
let swatches: [String]
}
struct ChoiceGroup: Codable, Hashable, Identifiable {
let id: String
let title: String
let options: [Choice]
}
struct BaseGroup: Codable, Hashable {
let id: String
let title: String
let options: [BaseOption]
}
struct Extra: Codable, Hashable, Identifiable {
let id: String
let label: String
let price: Int
}
struct Finish: Codable, Hashable, Identifiable {
let id: String
let label: String
let image: String?
}
struct FinishSection: Codable, Hashable {
let part: String
let category: String
let items: [Finish]
}
struct Product: Codable, Hashable, Identifiable {
var id: String { slug }
let slug: String
let title: String
let category: String
let isNew: Bool
let description: String
let priceFrom: Int?
let priceTo: Int?
let priceOnRequest: Bool
let priceWarnings: [String]
let images: [ImageSet]
let hasAr: Bool
let modelUrl: String?
let baseGroup: BaseGroup
let deltaGroups: [ChoiceGroup]
let extras: [Extra]
let finishes: [FinishSection]
}
struct ProductSummary: Codable, Hashable, Identifiable {
var id: String { slug }
let slug: String
let title: String
let category: String
let isNew: Bool
let priceFrom: Int?
let priceOnRequest: Bool
let hasAr: Bool
let image: ImageSet?
}
struct Category: Codable, Hashable, Identifiable {
let id: String
let title: String
let count: Int
}
Клиент
actor CatalogClient {
private let decoder: JSONDecoder = {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
return d
}()
private func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
var components = URLComponents(url: API.url(path), resolvingAgainstBaseURL: false)!
if !query.isEmpty { components.queryItems = query }
let (data, response) = try await URLSession.shared.data(from: components.url!)
guard let http = response as? HTTPURLResponse else { throw CatalogError.badResponse }
guard http.statusCode == 200 else {
throw CatalogError.http(status: http.statusCode, body: String(decoding: data, as: UTF8.self))
}
return try decoder.decode(T.self, from: data)
}
func categories() async throws -> [Category] {
try await get("v1/categories")
}
func products(category: String? = nil, hasAR: Bool? = nil) async throws -> [ProductSummary] {
var query: [URLQueryItem] = []
if let category { query.append(.init(name: "category", value: category)) }
if let hasAR { query.append(.init(name: "has_ar", value: hasAR ? "true" : "false")) }
return try await get("v1/products", query: query)
}
func product(_ slug: String) async throws -> Product {
try await get("v1/products/\(slug)")
}
}
enum CatalogError: Error {
case badResponse
case http(status: Int, body: String)
}
Расчёт цены
Правила приходят вместе с товаром, поэтому пересчёт мгновенный и работает без сети. Отдельного запроса на каждое нажатие нет и не нужно: в режиме дополненной реальности сетевая задержка на переключении размера была бы заметна сразу.
struct Configuration {
/// Идентификатор выбранного варианта базовой группы (размер или ткань).
var baseOptionID: String
/// [идентификатор группы: идентификатор выбранного варианта]
var selections: [String: String]
/// Отмеченные опции
var extras: Set<String>
}
extension Product {
/// Конфигурация по умолчанию: первый вариант в каждой группе, опции сняты.
var defaultConfiguration: Configuration {
Configuration(
baseOptionID: baseGroup.options[0].id,
selections: Dictionary(uniqueKeysWithValues: deltaGroups.compactMap { group in
group.options.first.map { (group.id, $0.id) }
}),
extras: []
)
}
func price(for config: Configuration) -> Int? {
guard let base = baseGroup.options.first(where: { $0.id == config.baseOptionID }) else {
return nil
}
let deltas = config.selections.reduce(0) { sum, pair in
sum + (base.priceMatrix[pair.key]?[pair.value] ?? 0)
}
let options = config.extras.reduce(0) { $0 + (base.optionPrices[$1] ?? 0) }
return base.price + deltas + options
}
}
Никаких ветвлений по типу товара здесь нет намеренно: на сайте-источнике цена считается тремя разными способами, но всё это разобрано на стороне сервера. Подробности — в PARSER.md.
Форматирование под рубли:
extension Int {
var asRubles: String {
let f = NumberFormatter()
f.numberStyle = .currency
f.currencyCode = "RUB"
f.maximumFractionDigits = 0
f.locale = Locale(identifier: "ru_RU")
return f.string(from: NSNumber(value: self)) ?? "\(self) ₽"
}
}
Экран товара
Набор групп выбора у разных товаров различается: у стола это размер, материал и опции, у стула — только ткань, у восьми изделий добавляется фасад. Поэтому экран строится циклом по тому, что пришло с сервера, а не фиксированной вёрсткой.
struct ConfiguratorView: View {
let product: Product
@State private var config: Configuration
init(product: Product) {
self.product = product
_config = State(initialValue: product.defaultConfiguration)
}
var body: some View {
VStack(alignment: .leading, spacing: 24) {
// базовая группа — размер или ткань
picker(title: product.baseGroup.title,
options: product.baseGroup.options.map { ($0.id, $0.label) },
selection: $config.baseOptionID)
// группы надбавок
ForEach(product.deltaGroups) { group in
picker(title: group.title,
options: group.options.map { ($0.id, $0.label) },
selection: Binding(
get: { config.selections[group.id] ?? "" },
set: { config.selections[group.id] = $0 }))
}
// опции-галочки
ForEach(product.extras) { extra in
Toggle(extra.label, isOn: Binding(
get: { config.extras.contains(extra.id) },
set: { on in
if on { config.extras.insert(extra.id) }
else { config.extras.remove(extra.id) }
}))
}
if let total = product.price(for: config) {
Text(total.asRubles).font(.title2.bold())
} else if product.priceOnRequest {
Text("Цена по запросу").foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private func picker(title: String, options: [(String, String)], selection: Binding<String>) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.subheadline).foregroundStyle(.secondary)
Picker(title, selection: selection) {
ForEach(options, id: \.0) { Text($0.1).tag($0.0) }
}
.pickerStyle(.segmented)
}
}
}
Дополненная реальность
Готовая модель
Кнопку AR показывайте только там, где hasAr равно true. Пока каталог
models/ на сервере пуст, это не сработает ни на одном товаре — см.
OPERATIONS.md.
import QuickLook
struct ARQuickLook: UIViewControllerRepresentable {
let modelURL: URL
func makeUIViewController(context: Context) -> QLPreviewController {
let controller = QLPreviewController()
controller.dataSource = context.coordinator
return controller
}
func updateUIViewController(_ controller: QLPreviewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(url: modelURL) }
final class Coordinator: NSObject, QLPreviewControllerDataSource {
let url: URL
init(url: URL) { self.url = url }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController,
previewItemAt index: Int) -> QLPreviewItem {
url as QLPreviewItem
}
}
}
Quick Look не открывает удалённые ссылки напрямую — файл нужно сначала сохранить локально:
func downloadModel(_ product: Product) async throws -> URL {
guard let path = product.modelUrl else { throw CatalogError.badResponse }
let remote = API.asset(path)
let destination = FileManager.default.temporaryDirectory
.appendingPathComponent("\(product.slug).usdz")
if FileManager.default.fileExists(atPath: destination.path) { return destination }
let (temp, _) = try await URLSession.shared.download(from: remote)
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: temp, to: destination)
return destination
}
USDZ хранит реальный масштаб внутри себя, поэтому для корректной постановки в комнате габариты из API не нужны. Они нужны для подписи размеров в интерфейсе и для процедурной геометрии.
Процедурная геометрия
Для круглых столов модель можно не готовить вовсе: диаметр и высота известны точно, а текстуру берём из образцов отделок. Так каталог получает AR без единого файла USDZ.
import RealityKit
func makeTable(for option: BaseOption, texture: TextureResource?) -> ModelEntity? {
let d = option.dimensions
guard d.parsed, d.shape == "round",
let diameterMm = d.diameterMm, let heightMm = d.heightMm else { return nil }
let radius = Float(diameterMm) / 2000 // миллиметры в метры
let height = Float(heightMm) / 1000
let topThickness: Float = 0.03
var material = PhysicallyBasedMaterial()
if let texture {
material.baseColor = .init(texture: .init(texture))
}
material.roughness = 0.35
material.metallic = 0.0
let top = ModelEntity(
mesh: .generateCylinder(height: topThickness, radius: radius),
materials: [material]
)
top.position.y = height - topThickness / 2
let base = ModelEntity(
mesh: .generateCylinder(height: height - topThickness, radius: radius * 0.18),
materials: [material]
)
base.position.y = (height - topThickness) / 2
let table = ModelEntity()
table.addChild(top)
table.addChild(base)
return table
}
Проверяйте dimensions.parsed перед построением: у четырёх товаров без
конфигуратора и двух барных стульев размеров в источнике нет.
Для прямоугольных изделий берите widthMm и depthMm, а у раздвижных
столов widthExtendedMm и diameterExtendedMm дают второе состояние —
хороший повод показать трансформацию прямо в комнате.
Изображения
Три размера на каждое фото: thumb (400 px) для списков, detail (1200 px)
для карточки, full (2000 px) для полноэкранного просмотра. Формат WebP,
который iOS поддерживает начиная с 14-й версии.
AsyncImage(url: API.asset(summary.image?.thumb ?? "")) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.secondary.opacity(0.1)
}
Изображения отдаются с Cache-Control: immutable на год, поэтому системного
кэша URLSession достаточно и своё хранилище заводить не нужно.
Образцы отделок из /media/_finishes/ общие для всех товаров — они
скачаются один раз и дальше будут браться из кэша.
Что стоит учесть
Цена по запросу. У четырёх товаров цены нет вовсе. Проверяйте
priceOnRequest, иначе в интерфейсе появится «0 ₽».
Одна ошибка в данных. У товара harry размер 2400 мм стоит 35 900 ₽
при соседних 325 000 и 392 000 ₽ — на сайте-источнике потеряна цифра.
Значение оставлено как есть; список замечаний лежит в priceWarnings.
Каталог заморожен. Данные снимались один раз и не обновляются. Опрашивать сервер на предмет изменений незачем — достаточно закэшировать список товаров при первом запуске.