Создание SwiftUI iOS приложения “Карта с поиском”  в тандеме с ИИ Gemini 2.5 Pro Experimental 

iOS 17 принесла разработчикам SwiftUI настоящий подарок от MapKit — кучу новых фишек, которые не потребуют никаких шаманских бубнов и костылей из UIKit

  • новый Map (position:selection:content:) задает положение камеры position и отслеживает  выбор selection на карте,
  • внутрь карты Map можно добавлять аннотации, маркеры, полигоны, маршруты через обычный SwiftUI DSL (ForEach, Marker, Annotation и т.д.), который называется MapContentBuilder и позволяет строить карты декларативно, как обычный View,
  • LookAroundPreview (аналог панорам улиц) можно показать для конкретной точки на карте,  
  • теперь стандартные элементы управления картой добавляются через модификаторы: компас .mapControl(.compass), пользователь mapControl(.userLocation), масштабная линейка .mapControl(.scaleView),
  • данные поисковых подсказок  MKLocalSearchCompletion теперь легко попадают в SwiftUI через @State.

Чтобы разобраться с новыми API, я решила поработать с небольшим скромным iOS приложением, которое описывается в посте Building a searchable map with SwiftUI and MapKit  (перевод этого поста на русский находится здесь) вместе с блестящим ИИ от Google, Gemini 2.5 Pro Experimental, чтобы привести его в работоспособное состояние.

Вот как оно работает:

  1. Вводишь название города / места в текстовое поле внизу экрана.
  2. C вводом каждой новой буквы ниже формируется список поисковых подсказок (предложений suggestions).
  3.  Нажимаем Enter в строке поиска и карта показывает найденные места.
  4. Выбираешь маркер — снизу открывается Look Around панорама (если доступна).
  5. Кликая на разных маркерах, получаем внизу разные LookAroundPreview.
  6. Кликая в произвольном месте на карте за пределами LookAroundPreview, мы можем вернуть на экран поисковую строку со списком предложений suggestions и начать поиск заново.
  7. Можно также выбирать нужное вам местоположение прямо из списка предложений suggestions, в этом случае внизу также появляется  LookAroundPreview.

К сожалению, после нескольких этапов общения с этим приложением, оно заканчивается аварийно с ошибкой Thread 1: EXC_BAD_ACCESS (code=1, address=0x68), которая говорит о том, что мы обращаемся к объекту, который уже ушел из нашего поля зрения. 

В целом мне нравятся идеи, заложенные в этом iOS приложении, связанные с тем, что интересующее местоположение и соответствующий ему LookAroundPreview пользователь может выбирать как кликая непосредственно на карте нужный маркер, так и кликая нужную строку в списке поисковых предложений. Жаль, что код написан с ошибками и не позволяет это делать сколько угодно раз, не выходя из приложения.

Моя задача заключалась в том, чтобы объединиться с блестящим ИИ от Google, Gemini 2.5 Pro Experimental, и привести это приложение в божеский вид. 

Я смотрю на современные продвинутые ИИ как на мощного «второго пилота», а не как на полностью автономного разработчика. На него возлагается создание устойчиво работающего приложения, улучшение его структуры, добавление крутых фичей вроде LookAroundPreview из MapKit, реализация возможности искать нужные места на карте снова и снова, не перезапуская приложение, и в целом – написание кода, достойного внимания Apple на следующей WWDC!

Итак, Gemini 2.5 Pro Experimental — это потрясающий “второй пилот”, но вам все еще нужен квалифицированный пилот в кресле командира, готовый взять управление, когда автопилот запутается или попытается пролететь сквозь гору, которую он сам же и сгаллюционировал.

Все это вы можете увидеть в протоколе взаимодействия с ИИ Gemini 2.5 Pro Experimental при создании SwiftUI iOS приложения “Карта с поиском”.

В этой статье я покажу лишь отдельные этапы этого взаимодействия.

ИИ Gemini 2.5 Pro Experimental для создания устойчиво работающего приложения

Наше кодинг-путешествие началось, честно говоря, на удивление гладко.

Я просила Gemini 2.5 Pro выполнить рефакторинг кода iOS приложения из поста Building a searchable map with SwiftUI and MapKit, работающего с ошибками, и создать SwiftUI iOS приложение для “Карт с поиском”, как показано на прикрепленном изображении.

Gemini 2.5 Pro Experimental особенно привлекает тем, что способен генерировать превосходный код прямо по скриншотам, без каких-либо технических спецификаций, основываясь лишь на принципах рефакторинга. Это почти как если бы он мог читать мысли разработчика через экран!

Ответ Gemini 2.5 Pro:

Хорошо, давайте создадим SwiftUI приложение Searchable Map на основе снимка экрана и с учетом лучших практик из рефакторинга кода, который вы предоставили ранее.

Эта версия будет:

  1. Использовать Map View из MapKit.
  2. Отображать в нижней части экрана лист SheetView с помощью модификатора .sheet для поиска мест.
  3. Использовать MKLocalSearchCompleter для получения поисковых подсказок в виде предложений.
  4. Использовать MKLocalSearch для получения фактических результатов поиска  в виде местоположений на карте (маркера Marker).
  5. Отображать результаты в виде аннотаций маркеров Marker на карте.
  6. Управлять состоянием с помощью @State и @Observable.
  7. Использовать  LocationService в SheetView.
  8. Автоматически закрывать лист SheetView при получении результатов.
  9. При желании масштабировать / панорамировать (zoom / pan) карту для отображения результатов.

Эта настройка обеспечивает функциональный поиск по карте, аналогичный снимку экрана, созданный с использованием современных методов SwiftUI.

Я не буду приводить здесь код, предложенный Gemini 2.5 Pro, его можно посмотреть в подробном протоколе взаимодействия с этим ИИ, a покажу, как функционирует этот вариант приложения.

Если в поисковой строке набрать “San Diego” и нажать Enter ( т.е. Submit)

Появляются маркеры Marker на карте, которые мы можем кликать, в этом случае на консоле мы видим наименование места, соответствующего этому маркеру Marker. Пока при клике на маркере Marker LookAroundPreview не появляется (собственно этого и не было в нашем задании Gemini 2.5 Pro),  но упоминание о его возможной активации в коде в нужном месте есть.

К сожалению, не удается снова вызвать на экран SheetView с поисковой строкой для возобновления поиска.

A вот как функционирует приложение, если кликнуть в списке поисковых подсказок, расположенных ниже поисковой строки:

На карте появляется один или более маркеров Marker для выбранной из списка подсказки. И опять SheetView с поисковой строкой не удается снова вызвать на экран для  возобновления поиска.

Поэтому мы просим Gemini 2.5 Pro Experimental помочь нам спроектировать UI так, чтобы можно было возобновить поиск.

Возобновить поиск повторным вызовом SheetView на экран.

Ответ Gemini 2.5 Pro:

Хорошо, вы попали в очень важную точку UI! Текущий код автоматически закрывает лист SheetView, как только будут найдены результаты поиска, что отлично подходит для первого поиска, но не позволяет легко начать новый.

Есть несколько способов справиться с этим, от простых до более интегрированных. Самым простым и рекомендуемым является: 

Вариант 1: добавить кнопку Button для повторного показа листа SheetView. 

Обычно это наиболее удобный и распространенный шаблоном для приложений с картами Map. Он дает четкое представление результатов после первого поиска и обеспечивает очевидный способ инициализаций последующих поисков. Код можно посмотреть в  протоколе взаимодействия с Gemini 2.5 Pro.

Но у меня ошибка в предложенном коде:

Я спрашиваю об ошибке Gemini 2.5 Pro Experimental:

Ответ Gemini 2.5 Pro:

Вы абсолютно правы! Приношу свои извинения. Размещение замыкания onDismiss было неверным в предыдущем примере. Оно должно быть отдельным аргументом для модификатора .sheet перед замыканием, содержащим основной контент content.

Gemini 2.5 Pro исправляет код, который я здесь не привожу, код можно посмотреть в  протоколе взаимодействия с Gemini 2.5 Pro.

Действительно, ошибки исчезли, запускаем приложение.

Как только задали в строке поиска “San Francisco” и нажали Enter (.onSubmit), SheetView исчезает с экрана и появляются маркеры Marker с результатами поиска и кнопка  с “лупой” в верхней правой части экрана, нажатие на которую заставляет  SheetView  появиться вновь и продолжить поиск. 

Вот как это всё функционирует в динамике:

Вы можете до бесконечности осуществлять поиск нужных местоположений на карте. При выборе того, что вы хотите — либо заполнив строку поиска и нажав Enter, либо выбрав какое-то местоположение из списка предложений — SheetView со строкой поиска покидает экран, и вызвать его заново можно только с помощью кнопки, находящейся в правом верхнем углу.

Приложение работает превосходно, но хотелось бы отображать Look Around сцены с помощью нового LookAroundPreview в iOS 17 при выборе соответствующего местоположения либо на карте Map, либо в списке поисковых предложений. Эта функциональность была в нашем исходном приложении и сейчас она утеряна.

 LookAroundPreview при выборе местоположения 

Просим об этом Gemini 2.5 Pro Experimental  (и опять проще предоставить скриншот, чем детально описывать функциональные возможности):

Ключевые изменения, сделанные ИИ Gemini 2.5 Pro в коде:

  1. Функция fetchLookAroundScene: создана асинхронная async функция для обработки выборки MKLookAroundScene с помощью MKLookAroundSceneRequest. Она обновляет @State переменную lookAroundScene в зависимости от успеха или неудачи.
  2. .onChange(of: selectedResult) и .task(id: selectedResult?.id): теперь они запускают функцию fetchLookAroundScene при каждом изменении выбранного маркера. Использование обоих обеспечивает надежность.
  3. .safeAreaInset(edge: .bottom): этот модификатор добавляется к карте Map. Он условно отображает LookAroundPreview (обернутый в LookAroundPanelView), когда selectedResult не равен nil.
  4. LookAroundPanelView: Извлечен UI в отдельную View структуру для лучшей организации (необязательно, но хорошая практика). Он получает привязку сцены $scene и, по желанию, имя name.
  5. Обработка взаимодействия:
  • Когда начинается новый поиск (onChange(of: searchResults)), панель Look Around явно скрывается.
  • При нажатии кнопки поиска панель скрывается.
  • Когда selectedResult становится nil (маркер не выбран), вызывается fetchLookAroundScene с nil, что скрывает панель.
  1. Анимация: Добавлены .animation и .transition, чтобы панель плавно появлялась и исчезала.

Теперь, когда вы нажимаете на маркер Marker на карте Map, приложение попытается получить данные Look Around. В случае успеха LookAroundPanelView  выдвигается снизу вверх. Нажатие на другой маркер  Marker обновит панель Look Around, а нажатие за пределами маркеров или начало нового поиска скроет ее.

Запускаем приложение — получаем ошибку:

У каждого героя на пути встречаются испытания, и наше заключалось в битве с ужасным зверем, известным как Ошибка Компилятора SwiftUI: «Unable to type-check this expression in reasonable time.» (Невозможно проверить ТИП этого выражения за разумное время). Это способ SwiftUI сказать: «Эй, приятель, полегче! View, что ты построил, настолько сложное, что у меня мозг сейчас расплавился, пытаясь понять, какого оно вообще типа.» Такое часто случается со вложенными View и длинными цепочками модификаторов, особенно вокруг Map.

Ошибка «The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions»

Просим помочь Gemini 2.5 Pro Experimental:

Gemini 2.5 Pro Experimental как храбрый рыцарь, бросился в бой:

— Давайте вынесем логику из .onChange, .onDismiss в отдельные private вспомогательные функции (handleSearchResultsChange, handleSelectionChange, handleSheetDismiss), что в любом случае является хорошей практикой так как значительно упрощает переменную var body основного View

— Может, обернем Map и ее непосредственные модификаторы ( .mapStyle, .ignoresSafeArea) в базовый контейнер, такой как ZStack или VStack, а затем применить более сложные модификаторы взаимодействия (.onChange, .task, .overlay, .safeAreaInset, .sheet, .alert) к этому контейнеру?

— Комментируем разделы модификаторов и содержимое карты (ForEach) один за другим, чтобы точно определить, какая комбинация вызывает ошибку.

После нескольких итераций определяем, что строка кода

.ignoresSafeArea(edges: .top, treatingAs: .edge)

, предложенная ранее Gemini 2.5 Pro, является токсичной и вызывает ошибку «Компилятор не может выполнить проверку ТИПов этого выражения за разумное время; попробуйте разбить выражение на отдельные подвыражения». 

Давайте спросим об этом Gemini 2.5 Pro Experimental:

 Ответ Gemini 2.5 Pro:

Вы абсолютно правы! Еще раз приношу свои извинения. Параметр treatingAs не существует для модификатора .ignoresSafeArea.

Этот модификатор просто принимает Optional  параметр sides, чтобы указать, какие края безопасные области следует игнорировать.

Замените неправильную строку 

.ignoresSafeArea(edges: .top, treatingAs: .edge) // <--- WRONG

на правильную:

ignoresSafeArea(edges: .top) // <--- CORRECT: Ignore top safe area

Спасибо, что заметили эту ошибку! Это должно устранить все ошибки, связанные с этой конкретной строкой.

Убираем ошибочную строку, возвращаемся к варианту без ZStack  

Ответ Gemini 2.5 Pro:

Хорошо, понял. Вернемся к структуре, в которой модификаторы применяются непосредственно к Map View и используем .safeAreaInset(edge: .bottom) для LookAroundPanelView.

Даже без ZStack мы все равно должны сохранить вычисляемые свойства для наложения overlay (searchButtonOverlay) и содержимого листа Sheet (sheetContent), чтобы помочь компилятору.

Вот код SearchableMap.swift, измененный в соответствии с вашим запросом:

import SwiftUI
import MapKit
struct SearchableMap: View {
    // --- State Variables ---
    @State private var locationService = LocationService()
    @State private var position: MapCameraPosition = .automatic
    @State private var selectedResult: SearchResult?
    @State private var isSheetPresented: Bool = true
    @State private var showSearchButton: Bool = false
    @State private var lookAroundScene: MKLookAroundScene?
 
    // Computed property
    var searchResults: [SearchResult] { locationService.searchResults }
    var body: some View {
        Map(position: $position, selection: $selectedResult) {
            // Map content: Markers for search results
            ForEach(searchResults) { result in
                Marker(result.name, coordinate: result.location)
                    .tag(result)
            }
        }
        // --- Apply Modifiers Directly to Map ---
        .mapStyle(.standard(elevation: .realistic))
       
        // --- State Change Handlers ---
        .onChange(of: searchResults) { _, newResults in handleSearchResultsChange(newResults) }
        .onChange(of: selectedResult) { _, newValue in handleSelectionChange(newValue) }
        .task(id: selectedResult?.id) { await fetchLookAroundScene(for: selectedResult) } // Robust fetch
        // --- Search Button Overlay ---
        .overlay(alignment: .topTrailing) {
            // Use computed property for the button content
            searchButtonOverlay
        }
        // Apply animation specifically for the button's appearance/disappearance
        .animation(.easeInOut, value: showSearchButton)
       
        // --- Look Around Panel using safeAreaInset ---
        .safeAreaInset(edge: .bottom) {
            // Conditionally display the panel using the computed property
            lookAroundPanel // Ensures animation applies correctly
        }
        // --- Bottom Sheet Presentation ---
        .sheet(isPresented: $isSheetPresented, onDismiss: handleSheetDismiss) {
            // Use computed property for sheet content
            sheetContent
        }
        // --- Optional: Error Alert ---
        .alert("Location Service Error", isPresented: locationService.isErrorPresented, presenting: locationService.error) { _ in Button("OK") {} } message: { error in Text(error.localizedDescription) }
    } // End of body
    // MARK: - Computed Properties for Sub-Views (Helps Compiler)
    // Computed property for the Look Around Panel content
    @ViewBuilder
    private var lookAroundPanel: some View {
        if selectedResult != nil { // Condition checked here
            LookAroundPanelView(scene: $lookAroundScene, resultName: selectedResult?.name)
                .frame(height: 150) // Define size here
                .clipShape(RoundedRectangle(cornerRadius: 15))
                .background(.thinMaterial)
                .cornerRadius(15)
                .shadow(radius: 5)
                .padding(.horizontal) // Add horizontal padding
                .padding(.bottom, 10) // Add some padding from the absolute bottom edge
                .transition(.move(edge: .bottom).combined(with: .opacity)) // Animation transition
        }
    }
    // Computed property for the search button overlay content
    @ViewBuilder
    private var searchButtonOverlay: some View {
        if showSearchButton {
            Button {
                selectedResult = nil
                showSearchButton = false
                isSheetPresented = true
            } label: {
                Image(systemName: "magnifyingglass")
                    .font(.title2)
                    .padding(12)
                    .background(.thinMaterial, in: Circle())
                    .shadow(radius: 3)
            }
            .padding() // Padding from screen edges
            .transition(.scale.combined(with: .opacity))
        } else {
            EmptyView()
        }
    }
    // Computed property for the sheet's content view
    private var sheetContent: some View {
        SheetView(locationService: locationService)
            .presentationDetents([.height(250), .medium, .large])
            .presentationBackground(.regularMaterial)
            .presentationBackgroundInteraction(.enabled(upThrough: .medium))
            .presentationCornerRadius(15)
            // Optional: .interactiveDismissDisabled()
    }
    // MARK: - Helper Functions / Handlers (Keep As Is)
    private func handleSearchResultsChange(_ newResults: [SearchResult]) {
        if !newResults.isEmpty {
            isSheetPresented = false
            showSearchButton = true
           
            if newResults.count == 1 { selectedResult = newResults.first }
            if let region = locationService.region {
                withAnimation(.smooth(duration: 0.7)) { position = .region(region) }
            }
        } else {
            showSearchButton = false
        }
    }
    private func handleSelectionChange(_ newValue: SearchResult?) {
        print("Selected result changed to: \(newValue?.name ?? "None")")
        if newValue == nil && !isSheetPresented && !searchResults.isEmpty { showSearchButton = true }
   
        // Fetching handled by .task
    }
    private func handleSheetDismiss() {
        print("Sheet dismissed manually.")
        if !locationService.searchResults.isEmpty { showSearchButton = true }
    }
    private func fetchLookAroundScene(for result: SearchResult?) async {
        guard let result = result else {
                self.lookAroundScene = nil
            return
        }
        print("Fetching Look Around scene for \(result.name)...")
        let request = MKLookAroundSceneRequest(coordinate: result.location)
        do {
            let scene = try await request.scene
            self.lookAroundScene = scene
            //----
            isSheetPresented = false
            //----
            print("Look Around scene fetched successfully: \(scene != nil)")
        } catch {
            print("❌ Failed to fetch Look Around scene: \(error.localizedDescription)")
            self.lookAroundScene = nil
        }
    }
}
// MARK: - Extracted Look Around Panel View (Keep As Is)
struct LookAroundPanelView: View {
    @Binding var scene: MKLookAroundScene?
    var resultName: String?
    var body: some View {
        VStack(alignment: .leading, spacing: 5) {
            if let name = resultName {
                Text(name)
                    .font(.caption.weight(.semibold))
                    .padding(.horizontal, 8)
                    .padding(.top, 8)
            }
            LookAroundPreview(scene: $scene, allowsNavigation: true, badgePosition: .bottomTrailing)
        }
    }
}

// MARK: - Observable Extension for Alert Binding (Keep As Is)
extension LocationService {
    var isErrorPresented: Binding<Bool> {
        Binding(get: { self.error != nil }, set: { if !$0 { self.error = nil } })
    }
}
// MARK: - Preview
#Preview {
    SearchableMap()
}

Вот код LocationService.swift

import MapKit
import CoreLocation // Ensure CoreLocation is imported
// Represents a search suggestion
struct SearchCompletions: Identifiable {
    let id = UUID()
    let title: String
    let subTitle: String
    // We can store the original MKLocalSearchCompletion if needed for more complex actions later
    // let mapKitCompletion: MKLocalSearchCompletion
}
// Represents a pin/marker on the map
struct SearchResult: Identifiable, Hashable, Equatable {
    let id = UUID()
    let name: String
    let location: CLLocationCoordinate2D
    let mapItem: MKMapItem // Store the original map item
    // Conformance for Map selection and comparison
    static func == (lhs: SearchResult, rhs: SearchResult) -> Bool {
        lhs.id == rhs.id
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}
@Observable // Use @Observable for modern Swift concurrency state management
class LocationService: NSObject, MKLocalSearchCompleterDelegate {
    private let completer: MKLocalSearchCompleter
    private var currentSearch: MKLocalSearch?
    // --- Published Properties (Observable updates views) ---
    var completions = [SearchCompletions]()
    var searchResults = [SearchResult]()
    var error: Error?
    var region: MKCoordinateRegion? // Optional region to focus the map
    // --- Initialization ---
    override init() {
        self.completer = MKLocalSearchCompleter()
        super.init()
        self.completer.delegate = self
        // Configure completer (optional: filter results, etc.)
        self.completer.resultTypes = [ .pointOfInterest]//[.address, .pointOfInterest]
        // Optionally filter by region if needed
        // self.completer.region = MKCoordinateRegion(.world)
    }
    // --- Public Methods ---
    func update(queryFragment: String) {
        // Reset previous results and errors on new query
        self.completions = []
        self.error = nil
        completer.queryFragment = queryFragment
    }
    // --- MKLocalSearchCompleterDelegate ---
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        // Map completer results to our SearchCompletions struct
        self.completions = completer.results.map { completion in
            // Extracting title parts can be useful if subtitle is long
            // let title = completion.title
            // let subTitle = completion.subtitle
            .init(title: completion.title, subTitle: completion.subtitle /*, mapKitCompletion: completion */)
        }
        self.error = nil
    }
    func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
        print("❌ Completer failed with error: \(error.localizedDescription)")
        self.error = error
        self.completions = []
    }
    // --- Search Execution ---
    func search ( with query: String) async {
        // Ensure query isn't empty
        guard !query.isEmpty else {
            self.searchResults = []
            self.region = nil
            return
        }
        // Cancel any ongoing search
        currentSearch?.cancel()
        // Prepare the search request
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = query
      // request.resultTypes = [.address, .pointOfInterest]
        request.resultTypes = [ .pointOfInterest]
        // You could bias search results towards the current map view region if desired
        // if let mapRegion = self.currentMapRegion { // Assuming you have a way to get the current map region
        //     request.region = mapRegion
        // }
        let search = MKLocalSearch(request: request)
        self.currentSearch = search
        self.error = nil // Reset error before search
        print("➡️ Performing search for: \(query)")
        do {
            let response = try await search.start()
            self.currentSearch = nil // Clear current search reference
            // Map MKMapItems to our SearchResult struct
            self.searchResults = response.mapItems.compactMap { mapItem in
                guard let location = mapItem.placemark.location?.coordinate else { return nil }
                return SearchResult(name: mapItem.name ?? "Unknown Place", location: location, mapItem: mapItem)
            }
            // Calculate a region encompassing all results
            self.region = calculateRegion(for: self.searchResults)
            print("✅ Search successful, found \(self.searchResults.count) results.")
        } catch {
            self.currentSearch = nil // Clear current search reference
            // Handle errors, including cancellation
            if let nsError = error as NSError?, nsError.code == NSUserCancelledError {
                print(" Canceled search.")
                // Don't necessarily clear results if cancelled due to new search starting
            } else if let mkError = error as? MKError, mkError.code == .placemarkNotFound {
                print("ℹ️ No results found for '\(query)'.")
                self.searchResults = []
                self.region = nil
                // Optionally set a specific "not found" error state instead of generic error
            } else {
                print("❌ Search failed with error: \(error.localizedDescription)")
                self.error = error
                self.searchResults = []
                self.region = nil
            }
        }
    }
    // Helper function to search based on a completion item
    func search(for completion: SearchCompletions) async {
        // Create a more specific query from the completion
        let query = "\(completion.title), \(completion.subTitle)"
        await search(with: query)
    }
    // --- Helper for calculating region ---
    private func calculateRegion(for results: [SearchResult]) -> MKCoordinateRegion? {
        guard !results.isEmpty else { return nil }
        if results.count == 1, let firstResult = results.first {
            // For a single result, center on it with a reasonable span
            return MKCoordinateRegion(center: firstResult.location, latitudinalMeters: 1000, longitudinalMeters: 1000) // 1km span
        } else {
            // For multiple results, calculate bounding box
            var minLat = 90.0
            var maxLat = -90.0
            var minLon = 180.0
            var maxLon = -180.0
            for result in results {
                let coord = result.location
                minLat = min(minLat, coord.latitude)
                maxLat = max(maxLat, coord.latitude)
                minLon = min(minLon, coord.longitude)
                maxLon = max(maxLon, coord.longitude)
            }
            let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2.0, longitude: (minLon + maxLon) / 2.0)
            let span = MKCoordinateSpan(latitudeDelta: (maxLat - minLat) * 1.4, longitudeDelta: (maxLon - minLon) * 1.4) // Add padding (* 1.4)
            return MKCoordinateRegion(center: center, span: span)
        }
    }
}

Вот код SheetView.swift

import SwiftUI
import MapKit
struct SheetView: View {
    // Use @Bindable for two-way binding with @Observable service
    @Bindable var locationService: LocationService
    @State private var search: String = ""
    // Environment variable to dismiss the sheet
    @Environment(\.dismiss) var dismiss
    var body: some View {
        VStack(spacing: 0) { // Use spacing 0 and add padding manually if needed
            // Search Bar Area
            HStack {
                Image(systemName: "magnifyingglass")
                    .foregroundColor(.secondary)
                TextField("Search for a place or address", text: $search)
                    .autocorrectionDisabled()
                    .onSubmit(performSearch) // Search when return key is pressed
            }
            .modifier(TextFieldGrayBackgroundColor()) // Apply custom style
            .padding()
            Divider()
            // List of Completions
            List {
                // Display suggestions if available
                ForEach(locationService.completions) { completion in
                    Button(action: { didTapOnCompletion(completion) }) {
                        VStack(alignment: .leading, spacing: 4) {
                            Text(completion.title)
                                .font(.headline)
                                .foregroundColor(.primary) // Ensure text is readable
                            Text(completion.subTitle)
                                .font(.subheadline)
                                .foregroundColor(.secondary)
                        }
                    }
                    .listRowBackground(Color.clear) // Optional: Make background transparent
                }
                // Show message if query entered but no completions
                if !search.isEmpty && locationService.completions.isEmpty && locationService.error == nil {
                    ContentUnavailableView.search(text: search)
                        // .listRowBackground(Color.clear) // Match background if needed
                }
            }
            .listStyle(.plain)
            .scrollContentBackground(.hidden) // Hide default list background
        }
        // Update completions live as the user types
        .onChange(of: search) { _, newValue in
            locationService.update(queryFragment: newValue)
        }
        // You might want to add error display within the sheet too
        .alert("Search Error", isPresented: Binding(get: { locationService.error != nil }, set: { _ in locationService.error = nil }), presenting: locationService.error) { _ in
            Button("OK") {}
        } message: { error in
            Text(error.localizedDescription)
        }
    }
    // MARK: - Actions
    private func performSearch() {
        guard !search.isEmpty else { return }
        Task {
            await locationService.search(with: search)
            // Sheet dismissal is now handled by SearchableMap's onChange(of: searchResults)
        }
    }
    private func didTapOnCompletion(_ completion: SearchCompletions) {
        Task {
            await locationService.search(for: completion)
            // Sheet dismissal is now handled by SearchableMap's onChange(of: searchResults)
        }
    }
}
// MARK: - View Modifier for Text Field Style
struct TextFieldGrayBackgroundColor: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(12)
            .background(Color(.systemGray6)) // Use semantic colors
            .cornerRadius(10) // Slightly rounder corners
            .foregroundColor(.primary)
    }
}

Сделанные изменения:

  1. Удален внешний ZStack.
  2. Добавлено .safeAreaInset(edge: .bottom) непосредственно в Map View.
  3. Размещено вычисляемое свойство lookAroundPanel (содержащее условный LookAroundPanelView) внутри замыкания .safeAreaInset.
  4. Применено .overlay для кнопки Button поиска непосредственно в Map View с использованием вычисляемого свойства searchButtonOverlay.
  5. Применено .animation(.easeInOut, value: showSearchButton) после .overlay для анимации видимости кнопки.
  6. Применены .onChange, .task, .sheet, .alert и .ignoresSafeArea(edges: .top) непосредственно к Map View после других модификаторов.
  7. Удален .ignoresSafeArea(edges: .bottom), так как .safeAreaInset требует, чтобы нижняя безопасная область была правильно расположена.

Эта структура ближе к исходным примерам и использует .safeAreaInset как требовалось. Сохраняя вычисляемые свойства для содержимого наложения overlay и листа sheet, мы все еще даем компилятору хороший шанс справиться со сложностью.

Вот как функционирует полученное с помощью Gemini 2.5 Pro  iOS приложение с хорошо структурированным  и не сложным работающим кодом:

Поставленная задача решена: мы получили безупречно работающее iOS приложение “Карта с поиском”. Код находится на GitHub.

Преимущества использования Gemini 2.5 Pro:

  1. Мгновенная магия рефакторинга: Помните тот слегка запутанный LocationService? Gemini 2.5 Pro распутала провода, чтобы SheetView не создавал свой собственный экземпляр сервиса где попало. Он также привел в порядок модели данных. Это было похоже на супер-быстрого ассистента, которому действительно нравится наводить порядок в коде. Почему это хорошо? Упрощает тестирование, предотвращает странные баги с состоянием и в целом делает код менее похожим на спагетти. Огромная экономия времени на рутине.
  2. Заклинатель фреймворков: Нужно получить подсказки для поиска из MapKit (MKLocalSearchCompleter)? Готово. Нужно выполнить реальный поиск (MKLocalSearch)? Без проблем. Нужен тот крутой LookAroundPreview? Gemini знал нужные API и сгенерировал базовую логику асинхронного получения данных (async/await) быстрее, чем я успела бы напечатать import MapKit. Он явно проглотил всю документацию по MapKit.
  3. Профи по распознаванию паттернов: Он не просто знал API, но и понимал, как их использовать вместе. Реализация логики «показать кнопку поиска после первого поиска» включала управление переменными @State и условным отображением UI – стандартные вещи, но Gemini собрал их воедино корректно, основываясь на запросе. Позже он даже предложил вынести сложную логики View в отдельные компоненты или вычисляемые свойства, что является отличной практикой.
  4. Строительные блоки: Он с радостью добавлял фичи последовательно. Мы начали с рефакторинга, добавили поиск, добавили Look Around, добавили кнопку повторного поиска… он вполне сносно отслеживал развивающуюся кодовую базу.
  5. Способность к объяснению: Мог пояснить логику и преимущества своих конкретных подходов к рефакторингу.

Недостатки использования Gemini 2.5 Pro:

  1. Синтаксические спотыкания: Иногда Gemini уверенно выдавал код с небольшими, но фатальными недостатками. Самым запоминающимся был ignoresSafeArea(edges: .top, treatingAs: .edge). Параметр treatingAs? Его не существует! Он сгаллюцинировал совершенно правдоподобный (но неверный) аргумент модификатора. Эта хитроумная ошибка, которую оказалось не так-то просто найти. Неправильное размещение onDismiss было другим примером.
  2. Жонглирование контекстом: Хотя в основном он справлялся, во время сложной отладки ошибки компилятора чувствовалось, что он почти теряет нить, какие модификаторы где применялись, что требовало более четких инструкций с моей стороны. Его краткосрочная память временами давала сбой под нагрузкой.
  3. Уверенность — ключ (Даже если не прав): ИИ преподносит и блестящий, и глючный код с абсолютно одинаковым уровнем уверенности в стиле «Держи, человек!». Нет никакого «Хм, насчет этого куска я не уверен…», что означает, что вы должны быть скептиком, тестировщиком, тем, кто перепроверяет его работу.

Философское заключение

В конце нашего цифрового тандема стало ясно: Gemini 2.5 Pro Experimental — это не волшебная палочка, исполняющая желания разработчика, а скорее талантливый, но иногда рассеянный стажёр. Он может молниеносно генерировать шаблонный код и даже предлагать элегантные архитектурные решения, но когда дело доходит до коварных ловушек декларативного SwiftUI в виде сложных иерархий Views или длинных цепочек модификаторов, ему всё ещё нужна твердая рука опытного наставника.

Если вы используете ИИ-помощника вроде Gemini 2.5 Pro Experimental для написания кода — не расслабляйтесь! Всегда проверяйте, что он вам нагенерил, особенно при поиске и устранении сложных ошибок и проверке соответствия архитектурным требованиям проекта.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *