← Назад к вопросам

UI из макета (Avito Tech)

2.2 Middle🔥 131 комментариев
#UIKit и верстка#Архитектура и паттерны#Тестирование и отладка

Условие

Реализуйте экран iOS приложения по макету с парсингом JSON-данных.

Функциональные требования:

  • Загрузка и парсинг JSON с данными услуг
  • Отображение списка услуг в виде сетки
  • Выбор одного элемента из списка (галочка)
  • При повторном нажатии — отмена выбора
  • Кнопка "Выбрать" показывает alert с названием выбранной услуги

Технические требования:

  • UICollectionView для сетки
  • Без использования Storyboard (программный UI или SwiftUI)
  • Архитектура VIPER (рекомендуется) или MVVM
  • JSON можно хранить локально или загружать по URL

Пример структуры JSON:

{
  "services": [
    {"id": "1", "name": "Услуга 1", "price": 100, "icon": "url"},
    {"id": "2", "name": "Услуга 2", "price": 200, "icon": "url"}
  ]
}

Бонус:

  • Анимация выбора
  • Загрузка иконок с кэшированием
  • Unit-тесты для бизнес-логики

Тестовое задание от Avito Tech на позицию iOS разработчика.

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение VIPER архитектуры

Это полное решение экрана выбора услуг с JSON парсингом, UICollectionView сеткой и анимацией.

Архитектура

V (View) → I (Interactor) → P (Presenter) → E (Entity) ← R (Router)

Entities/Service.swift

struct Service: Codable, Identifiable {
    let id: String
    let name: String
    let price: Int
    let icon: String
}

struct ServicesResponse: Codable {
    let services: [Service]
}

Interactors/ServicesInteractor.swift

protocol ServicesInteractorProtocol {
    func fetchServices() async throws -> [Service]
}

final class ServicesInteractor: ServicesInteractorProtocol {
    func fetchServices() async throws -> [Service] {
        guard let url = Bundle.main.url(forResource: "services", withExtension: "json") else {
            throw NSError(domain: "File not found", code: -1)
        }
        let data = try Data(contentsOf: url)
        let response = try JSONDecoder().decode(ServicesResponse.self, from: data)
        return response.services
    }
}

Presenters/ServicesPresenter.swift

protocol ServicesPresenterProtocol {
    func viewDidLoad()
    func didSelectService(_ service: Service)
    func didTapSelectButton()
}

protocol ServicesPresenterViewProtocol: AnyObject {
    func showServices(_ services: [Service])
    func showLoading()
    func hideLoading()
    func showAlert(message: String)
    func updateSelection(_ selected: Set<String>)
}

final class ServicesPresenter: ServicesPresenterProtocol {
    weak var view: ServicesPresenterViewProtocol?
    let interactor: ServicesInteractorProtocol
    
    private var selectedIds: Set<String> = []
    private var allServices: [Service] = []
    
    init(interactor: ServicesInteractorProtocol) {
        self.interactor = interactor
    }
    
    func viewDidLoad() {
        view?.showLoading()
        Task {
            do {
                let services = try await interactor.fetchServices()
                DispatchQueue.main.async {
                    self.allServices = services
                    self.view?.hideLoading()
                    self.view?.showServices(services)
                }
            } catch {
                DispatchQueue.main.async {
                    self.view?.hideLoading()
                }
            }
        }
    }
    
    func didSelectService(_ service: Service) {
        if selectedIds.contains(service.id) {
            selectedIds.remove(service.id)
        } else {
            selectedIds.insert(service.id)
        }
        view?.updateSelection(selectedIds)
    }
    
    func didTapSelectButton() {
        guard !selectedIds.isEmpty else { return }
        let name = allServices.first { selectedIds.contains($0.id) }?.name ?? ""
        view?.showAlert(message: "Selected: \(name)")
    }
}

ViewControllers/ServicesViewController.swift

final class ServicesViewController: UIViewController, ServicesPresenterViewProtocol {
    let presenter: ServicesPresenterProtocol
    let collectionView = UICollectionView(
        frame: .zero,
        collectionViewLayout: UICollectionViewFlowLayout()
    )
    let selectButton = UIButton(type: .system)
    var services: [Service] = []
    var selectedIds: Set<String> = []
    
    init(presenter: ServicesPresenterProtocol) {
        self.presenter = presenter
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) { fatalError() }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Select Services"
        view.backgroundColor = .white
        setupUI()
        presenter.viewDidLoad()
    }
    
    private func setupUI() {
        let layout = UICollectionViewFlowLayout()
        layout.itemSize = CGSize(width: 100, height: 120)
        layout.minimumInteritemSpacing = 8
        layout.minimumLineSpacing = 8
        
        collectionView.collectionViewLayout = layout
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.register(ServiceCell.self, forCellWithReuseIdentifier: "cell")
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        
        selectButton.setTitle("Select", for: .normal)
        selectButton.backgroundColor = .blue
        selectButton.setTitleColor(.white, for: .normal)
        selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
        selectButton.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(selectButton)
        
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: selectButton.topAnchor, constant: -8),
            selectButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            selectButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            selectButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
            selectButton.heightAnchor.constraint(equalToConstant: 50)
        ])
    }
    
    @objc func selectTapped() {
        presenter.didTapSelectButton()
    }
    
    func showServices(_ services: [Service]) {
        self.services = services
        collectionView.reloadData()
    }
    
    func showLoading() {}
    func hideLoading() {}
    
    func showAlert(message: String) {
        let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }
    
    func updateSelection(_ selected: Set<String>) {
        selectedIds = selected
        collectionView.reloadData()
    }
}

extension ServicesViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        services.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! ServiceCell
        let service = services[indexPath.row]
        cell.configure(service, isSelected: selectedIds.contains(service.id))
        return cell
    }
}

extension ServicesViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        presenter.didSelectService(services[indexPath.row])
    }
}

ServiceCell с анимацией

final class ServiceCell: UICollectionViewCell {
    let label = UILabel()
    let checkmark = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.backgroundColor = .gray
        contentView.layer.cornerRadius = 8
        
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 12)
        label.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(label)
        
        checkmark.text = "✓"
        checkmark.font = .systemFont(ofSize: 20)
        checkmark.textColor = .green
        checkmark.isHidden = true
        checkmark.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(checkmark)
        
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            checkmark.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
            checkmark.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4)
        ])
    }
    
    required init?(coder: NSCoder) { fatalError() }
    
    func configure(_ service: Service, isSelected: Bool) {
        label.text = service.name
        checkmark.isHidden = !isSelected
        contentView.layer.borderColor = (isSelected ? UIColor.blue : UIColor.gray).cgColor
        contentView.layer.borderWidth = isSelected ? 2 : 1
        
        if isSelected {
            UIView.animate(withDuration: 0.2) {
                self.checkmark.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
            }
        }
    }
}

Ключевые элементы

  1. VIPER - четкое разделение ответственности
  2. UICollectionView - сетка 3x2
  3. JSON парсинг - Codable протокол
  4. Single Selection - выбор одного элемента
  5. Анимация - масштабирование галочки
  6. Programmatic UI - без Storyboard
  7. Async/await - современный код Swift