Google AI Studio: макеты Figma -> iOS приложение на Gemini 2.5 Experimental

от автора

Мне случайно попалось задание с собеседования 2-х годичной давности на позицию «iOS разработчик» , в котором требовалось создать iOS приложения для бронирования номера в Отеле на основе макетов экранов из Figma, в которых использовалась «карусель» из фото.

Я решила попробовать использовать ИИ для разработки такого iOS приложения по макетам из Figma. Прямо скажем, что текст на прототипах экранов не очень отчетливо виден, так что ИИ предстоит его сначала прочитать, а уже потом создать iOS приложение.

Для меня приоритетными ИИ при создании iOS приложений являются ChatGPT 4.o-mini, Claude 3.5 (3.7) Sonnet и Grok 3, ну, может быть, DeepSeek R1, но Gemini 1.5 таким не был, хотя предоставлял корректный, но не самый лаконичный и интересный код.

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

Так что у нас есть все шансы на успех.

Забегая вперед, сразу скажу, что Gemini 2.5 Experimental превзошел ожидания: точное воспроизведение Figma-макетов в SwiftUI (стили, функционал) + нешаблонный код, а изобретательные решения (FlexibleGridView и др.). Стартовый код — база для масштабируемого приложения с реализацией загрузки данных с сервера (REST API). Код находится на Github.

Вот как выглядит протокол взаимодействия с Gemini 2.5 Experimental в Google AI Studio:

Отель и категории номеров

Ответ Gemini 2.5 Experimental

Хорошо, давайте создадим SwiftUI структуру для этого приложения с информацией об отеле на основе ваших снимков экрана.

Мы создадим:

  1. Модели данных: для представления информации об отеле и номере.

  2. HotelDetailView: главный экран, на котором отображаются сведения об отеле, карусель изображений, описание и ссылки на другие разделы.

  3. AmenitiesView: View-заполнитель для сведений об удобствах.

  4. RoomSelectionView: экран для отображения различных категорий номеров с их подробностями и изображениями.

  5. RoomCardView: повторно используемое View для отображения одной карточки категории номера.

  6. Вспомогательные View: например, ImageCarouselView и SectionRow.

Код HotelDetailView
import SwiftUI  // MARK: - Data Models  struct Hotel: Identifiable {     let id = UUID()     let name: String     let rating: Double     let ratingName: String     let address: String     let minimalPrice: Int     let priceForIt: String     let imageUrls: [String] // Use image names from Assets or URLs     let description: String     let peculiarities: [String] // Like "3-я линия", "Платный Wi-Fi в фойе" }  struct Room: Identifiable {     let id = UUID()     let name: String     let price: Int     let pricePer: String     let peculiarities: [String] // Like "Все включено", "Кондиционер"     let imageUrls: [String] }   // MARK: - Content View (Root)  struct ContentView: View {     // --- MOCK DATA ---     // In a real app, this would come from a ViewModel/API     let sampleHotel = Hotel(         name: "Steigenberger Makadi", // Updated name based on screenshot 4         rating: 5.0,         ratingName: "Превосходно",         address: "Madinat Makadi, Safaga Road, Makadi Bay, Египет",         minimalPrice: 134673,         priceForIt: "за тур с перелётом",         imageUrls: ["hotel_placeholder_1", "hotel_placeholder_2", "hotel_placeholder_3", "hotel_placeholder_4", "hotel_placeholder_5", "hotel_placeholder_6", "hotel_placeholder_7"], // Add your image names         description: "Отель VIP-класса с собственными гольф полями. Высокий уровень сервиса. Рекомендуем для респектабельного отдыха.",         peculiarities: ["3-я линия", "Платный Wi-Fi в фойе", "30 км до аэропорта", "1 км до пляжа"]     )       let sampleRooms = [         Room(name: "Стандартный с видом на бассейн или сад", price: 186600, pricePer: "за 7 ночей с перелётом", peculiarities: ["Все включено", "Кондиционер"], imageUrls: ["room1_image1", "room1_image2","room1_image3","room1_image4"]),         Room(name: "Люкс с видом на море", price: 250000, pricePer: "за 7 ночей с перелётом", peculiarities: ["Все включено", "Кондиционер", "Мини-бар"], imageUrls: ["suite1_image1", "suite1_image2", "suite1_image3"])         // Add more rooms     ]     // --- ---      var body: some View {         NavigationView {             HotelDetailView(hotel: sampleHotel, rooms: sampleRooms)         }         // Optional: Use .accentColor for global tint if needed         // .accentColor(.blue)     } }  // MARK: - Hotel Detail View  struct HotelDetailView: View {     let hotel: Hotel     let rooms: [Room] // Pass rooms data for navigation      @State private var selectedImageIndex = 0      var body: some View {         ScrollView {             VStack(alignment: .leading, spacing: 8) {                 // --- Section 1: Image Carousel, Basic Info ---                 VStack(alignment: .leading, spacing: 16) {                     ImageCarouselView(imageUrls: hotel.imageUrls,                                        selectedIndex: $selectedImageIndex)                         .frame(height: 250) // Adjust height as needed                         .clipShape(RoundedRectangle(cornerRadius: 15)) // Rounded corners                      HStack(spacing: 4) {                         Image(systemName: "star.fill")                             .foregroundColor(.orange)                         Text("\(hotel.rating, specifier: "%.1f") \(hotel.ratingName)")                             .foregroundColor(.orange)                             .font(.system(size: 16, weight: .medium))                     }                      Text(hotel.name)                         .font(.system(size: 22, weight: .medium))                      Button {                         // Action for address tap if needed (e.g., open map)                         print("Address tapped")                     } label: {                         Text(hotel.address)                             .font(.system(size: 14, weight: .medium))                             .foregroundColor(.blue) // Link-like appearance                     }                      HStack(alignment: .bottom, spacing: 8) {                          Text("от \(hotel.minimalPrice) ₽")                             .font(.system(size: 30, weight: .semibold))                          Text(hotel.priceForIt.lowercased())                             .font(.system(size: 16, weight: .regular))                             .foregroundColor(.secondary)                             .padding(.bottom, 4) // Align baseline better                     }                  }                 .padding(.horizontal)                 .padding(.vertical, 8) // Add some vertical padding                 .background(Color.white) // White background for this section                 .cornerRadius(15) // Rounded corners for the section                    // --- Section 2: About the Hotel ---                  VStack(alignment: .leading, spacing: 16) {                     Text("Об отеле")                         .font(.system(size: 22, weight: .medium))                      // Peculiarities (Tags)                     FlexibleGridView(data: hotel.peculiarities) { item in                         Text(item)                             .font(.system(size: 16, weight: .medium))                             .foregroundColor(.gray)                             .padding(.horizontal, 10)                             .padding(.vertical, 5)                             .background(Color(UIColor.systemGray6)) // Tag background                             .cornerRadius(5)                     }                       Text(hotel.description)                          .font(.system(size: 16, weight: .regular))                      // --- Detail Buttons ---                     VStack(spacing: 0) {                          NavigationLink(destination: AmenitiesView()) { // Navigate to Amenities                              SectionRow(iconName: "face.smiling",                                         title: "Удобства",                                         subtitle: "Самое необходимое")                          }                          Divider().padding(.leading, 45) // Indent divider                          SectionRow(iconName: "checkmark.circle",                                     title: "Что включено",                                      subtitle: "Основные услуги")                          Divider().padding(.leading, 45)                          SectionRow(iconName: "xmark.circle",                                      title: "Что не включено",                                      subtitle: "Дополнительные услуги")                     }                     .background(Color(UIColor.systemGray6))                     .cornerRadius(15)                     .padding(.top) // Add space before buttons                    }                  .padding(.horizontal)                  .padding(.vertical, 8)                  .background(Color.white)                  .cornerRadius(15)                  .padding(.top, 8) // Space between sections               } // End Main VStack         } // End ScrollView         .background(Color(UIColor.systemGray6).ignoresSafeArea()) // Background for the whole screen         .navigationBarTitleDisplayMode(.inline) // Keep title small        .toolbar { // Hide the default back button text             ToolbarItem(placement: .principal) {                  Text(hotel.name) // Or just "Отель"                     .font(.system(size: 18, weight: .medium))             }         }         .safeAreaInset(edge: .bottom) {              // --- Bottom Button ---              NavigationLink(destination:                    RoomSelectionView(hotelName: hotel.name, rooms: rooms)) {                   Text("К выбору номера")                      .font(.system(size: 16, weight: .medium))                      .foregroundColor(.white)                      .frame(maxWidth: .infinity)                      .padding()                      .background(Color.blue)                      .cornerRadius(15)              }              .padding(.horizontal)              .padding(.top, 8) // Add space above button              .background( // Add a white background behind the button area                  Color.white                     .ignoresSafeArea(edges: .bottom) // Extend white to screen bottom edge              )          }     } }  // MARK: - Image Carousel View  struct ImageCarouselView: View {     let imageUrls: [String]     @Binding var selectedIndex: Int      var body: some View {         TabView(selection: $selectedIndex) {             ForEach(0..<imageUrls.count, id: \.self) { index in                 Image(imageUrls[index]) // Assumes images are in Assets                     .resizable()                     .scaledToFill()                     // .aspectRatio(contentMode: .fill) // Alternative scaling                     .tag(index)                     // Add error handling/placeholder if needed                     .overlay{                         VStack {                             Spacer()                             HStack {                                 Spacer()                                 Text("\(index + 1) / \(imageUrls.count)")                                     .bold()                                     .foregroundStyle(Color.white)                                     .padding(.horizontal, 45)                             }                             .padding()                         }                     }             }         }         .tabViewStyle(.page(indexDisplayMode: .never)) // Hide default dots         .overlay(             // Custom Pagination Dots             HStack(spacing: 8) {                 ForEach(0..<imageUrls.count, id: \.self) { index in                     Circle()                         .fill(selectedIndex == index ? Color.black : Color.gray.opacity(0.7))                         .frame(width: 7, height: 7)                 }             }             .padding(.horizontal, 12)             .padding(.vertical, 8)             .background(Color.white.opacity(0.9))             .cornerRadius(5)             .padding(.bottom, 10) // Position dots from bottom             , alignment: .bottom // Align dots at the bottom         )     } }  // MARK: - Section Row Helper View  struct SectionRow: View {     let iconName: String     let title: String     let subtitle: String      var body: some View {         HStack(spacing: 12) {             Image(systemName: iconName)                 .resizable()                 .scaledToFit()                 .frame(width: 24, height: 24)                 .foregroundColor(.primary.opacity(0.9))              VStack(alignment: .leading) {                 Text(title)                     .font(.system(size: 16, weight: .medium))                     .foregroundColor(.primary)                 Text(subtitle)                     .font(.system(size: 14, weight: .regular))                     .foregroundColor(.secondary)             }             Spacer()             Image(systemName: "chevron.right")                 .foregroundColor(.secondary)         }         .padding(.horizontal)         .padding(.vertical, 10)     } }   // MARK: - Amenities View (Placeholder)  struct AmenitiesView: View {     var body: some View {         ScrollView { // Make it scrollable in case content grows             VStack {                  Text("Details about Удобства")                      .padding()                  // Add actual amenity details here (Lists, Grids, etc.)                  Spacer()             }         }         .navigationTitle("Удобства") // Standard navigation title         .navigationBarTitleDisplayMode(.inline)     } }  // MARK: - Room Selection View  struct RoomSelectionView: View {     let hotelName: String     let rooms: [Room]      var body: some View {         ScrollView {             VStack(spacing: 8) {                 ForEach(rooms) { room in                     RoomCardView(room: room)                 }             }             .padding(.vertical) // Add padding top/bottom inside scrollview         }         .navigationTitle(hotelName) // Use hotel name as title         .navigationBarTitleDisplayMode(.inline)         .background(Color(UIColor.systemGray6).ignoresSafeArea()) // Match background     } }  // MARK: - Room Card View  struct RoomCardView: View {     let room: Room     @State private var selectedImageIndex = 0 // Each card manages its own carousel state      var body: some View {         VStack(alignment: .leading, spacing: 16) {             ImageCarouselView(imageUrls: room.imageUrls,                                selectedIndex: $selectedImageIndex)                  .frame(height: 200) // Adjust height                  .clipShape(RoundedRectangle(cornerRadius: 15))              Text(room.name)                  .font(.system(size: 22, weight: .medium))               // Peculiarities (Tags)             FlexibleGridView(data: room.peculiarities) { item in                 Text(item)                     .font(.system(size: 16, weight: .medium))                     .foregroundColor(.gray)                     .padding(.horizontal, 10)                     .padding(.vertical, 5)                     .background(Color(UIColor.systemGray6))                     .cornerRadius(5)             }               // Room Price              HStack(alignment: .bottom, spacing: 8) {                   Text("\(room.price) ₽")                      .font(.system(size: 30, weight: .semibold))                   Text(room.pricePer.lowercased())                      .font(.system(size: 16, weight: .regular))                      .foregroundColor(.secondary)                      .padding(.bottom, 4)              }               // Choose Room Button              Button {                  // Action for selecting THIS specific room                  print("Selected room: \(room.name)")              } label: {                    Text("Выбрать номер")                       .font(.system(size: 16, weight: .medium))                       .foregroundColor(.white)                       .frame(maxWidth: .infinity)                       .padding()                       .background(Color.blue)                       .cornerRadius(15)              }          }         .padding()         .background(Color.white)         .cornerRadius(15)         .padding(.horizontal) // Add horizontal space between cards and screen edge     } }   // MARK: - Flexible Grid View (Helper for Tags)  struct FlexibleGridView<Data: Collection, Content: View>: View where Data.Element: Hashable {     let data: Data     let spacing: CGFloat     let alignment: HorizontalAlignment     let content: (Data.Element) -> Content     @State private var availableWidth: CGFloat = 0      init(data: Data, spacing: CGFloat = 8, alignment: HorizontalAlignment = .leading, @ViewBuilder content: @escaping (Data.Element) -> Content) {         self.data = data         self.spacing = spacing         self.alignment = alignment         self.content = content     }      var body: some View {     ZStack(alignment: Alignment(horizontal: alignment, vertical: .center)) {             Color.clear                 .frame(height: 1)                 .readSize { size in                     availableWidth = size.width                 }              _FlexibleGrid(                 availableWidth: availableWidth,                 data: data,                 spacing: spacing,                 alignment: alignment,                 content: content             )         }     } }  struct _FlexibleGrid<Data: Collection, Content: View>: View where Data.Element: Hashable {     let availableWidth: CGFloat     let data: Data     let spacing: CGFloat     let alignment: HorizontalAlignment     let content: (Data.Element) -> Content     @State var elementsSize: [Data.Element: CGSize] = [:]      var body: some View {         VStack(alignment: alignment, spacing: spacing) {             ForEach(computeRows(), id: \.self) { rowElements in                 HStack(spacing: spacing) {                     ForEach(rowElements, id: \.self) { element in                         content(element)                             .fixedSize()                             .readSize { size in                                 elementsSize[element] = size                             }                     }                 }             }         }     }      func computeRows() -> [[Data.Element]] {         var rows: [[Data.Element]] = [[]]         var currentRow = 0         var remainingWidth = availableWidth          for element in data {             let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)]              if remainingWidth - (elementSize.width + spacing) >= 0 {                 rows[currentRow].append(element)             } else {                 currentRow += 1                 rows.append([element])                 remainingWidth = availableWidth             }             remainingWidth -= (elementSize.width + spacing)         }         return rows     } }  // Helper to read view size struct SizePreferenceKey: PreferenceKey {     static var defaultValue: CGSize = .zero     static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} } extension View {     func readSize(onChange: @escaping (CGSize) -> Void) -> some View {         background(             GeometryReader { geometryProxy in                 Color.clear                     .preference(key: SizePreferenceKey.self, value: geometryProxy.size)             }         )         .onPreferenceChange(SizePreferenceKey.self, perform: onChange)     } }  #Preview {     ContentView() }

Объяснение: 

Модели данных (Hotel, Room): простые структуры struct для хранения данных об отелях и номерах. Identifiable нужен для использования в ForEach. Для простоты показана статическая модель. В реальном приложении данные могут приходить из API или базы данных.

2. HotelApp & ContentView: стандартная точка входа в приложение SwiftUI. ContentView настраивает NavigationView и инициализирует начальное View (HotelDetailView) с помощью тестовых данных.

3. HotelDetailView:

  • Использует ScrollView и VStack, для размещения по вертикали любого контента.

  • Включает ImageCarouselView.

  • Отображает рейтинг, имя, адрес (в виде кнопки), цену для этого отеля.

  • Добавляет раздел «Об отеле» с тегами (используя FlexibleGridView) и описанием.

  • Использует NavigationLink и SectionRow для разделов «Удобства», «Что включено», «Что не включено».

  • Большая синяя кнопка «К выбору номера» размещена внизу с помощью .safeAreaInset(edge: .bottom), чтобы она оставалась видимой и слегка перекрывала прокручиваемое содержимое. Это NavigationLink для RoomSelectionView.

  • .navigationBarTitleDisplayMode(.inline) сохраняет компактность панели навигации. 

4. ImageCarouselView:

  • Использует TabView с .tabViewStyle(.page).

  • .page(indexDisplayMode: .never) скрывает точки страницы по умолчанию.

  • Добавляется наложение для создания внизу пользовательских точек страниц, привязанных к переменной @State selectedIndex.

5. SectionRow: Многоразовое View для выбираемых строк, показывающих детали («Удобства», «Что включено» и т. д.). Включает иконку, заголовок, подзаголовок и шеврон.

6. AmenitiesView: Простое View-заполнитель. Вы можете заменить Text фактическим списком удобств.

7. RoomSelectionView:

  • Принимает hotelName (для заголовка) и массив объектов Room.

  • Использует ScrollView и VStack для размещения нескольких RoomCardViews.

  • Устанавливает заголовок навигации .navigationTitle.

8. RoomCardView:

  • Отображает сведения об одном номере Room, включая его собственный ImageCarouselView.

  • Показывает имя, теги (FlexibleGridView), цену и кнопку «Выбрать номер», характерную для этого номера.

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

9. FlexibleGridView и другие: вспомогательное View для динамического размещения тегов, переносит их на следующую строку, если они не помещаются по горизонтали. Включает помощника модификатора readSize.

10. Заполнители: не забудьте заменить «hotel1», «room1» и т. д. фактическими именами ваших изображений в файле Assets.xcassets.

Эта структура обеспечивает прочную основу, соответствующую дизайну, показанному на ваших снимках экрана. Теперь вы можете заполнить ее реальными данными (потенциально полученными из API с помощью ViewModel) и дополнительно уточнить детали UI.

Вот какой результат мы получили:

Экран с отелем:

Переход к выбору категории номера:

Дополнительная информация об удобствах:

А вот как работают «карусели» фотографий как для самого отеля, так и для различных категорий номеров:

  • Итоги работы с Gemini 2.5 + мвкеты Figma:

    1. Точность воспроизведения макетов Figma в SwiftUI:
      — Экран отеля и категорий номеров воссозданы на SwiftUI с сохранением стилей текста, функционала и локализации (русский язык).

    2. Автономная генерация данных:
      — Gemini 2.5 самостоятельно сформировала структуру тестовых данных без внешних API.

    3. Архитектура кода:
      — Лаконичный SwiftUI код с TabView для «карусели» фотографий и повторно используемыми компонентами: SectionView, ImageCarouselView.
      — Инновационный FlexibleGridView для адаптивного отображения элементов удобств.

    4. Ресурсы:
      — Тестовые изображения интегрированы через Assets.xcassets.

    Что улучшить:
    — Реализовать загрузку данных с сервера (REST API).
    — Оптимизировать передачу данных между View.
    — Усилить обработку ошибок и читаемость кода.

    Резюме: Стартовый код готов к доработке, демонстрируя высокий потенциал Gemini 2.5 в преобразовании Figma-макетов в рабочий iOS-код.

Вдохновившись таким результатом, давайте продолжим и создадим экран для бронирования номера отеля.

Бронирование номера отеля (часть 1)

Прототип экрана из Figma:

Делаем запрос Gemini 2.5 Experimental 03-25

Ответ Gemini 2.5 Experimental

Хорошо, давайте создадим SwiftUI View для экрана c информацией о бронировании, включив маску для номера телефона и проверку адреса электронной почты.

Мы разобьем его на компоненты:

  • BookingInfoView: основное View контейнера.

  • Вспомогательные Views: для отдельных блоков (отель, сведения о бронировании, информация о покупателе).

  • Логика форматирования номера телефона: использование @State и .onReceive, который является ключом к тому, чтобы шаблон работал в режиме реального ввода.

  • Логика проверки адреса электронной почты: использование @State, .onChangeи простого регулярного выражения.

Код BookingInfoView
import SwiftUI import Combine // Needed for keyboard observers if we wanted more advanced focus handling  // MARK: - Data Models (Simple representations for the example)  struct HotelBookingInfo {     let ratingValue: Int = 5     let ratingName: String = "Превосходно"     let hotelName: String = "Steigenberger Makadi"     let hotelAddress: String = "Madinat Makadi, Safaga Road, Makadi Bay, Египет" }  struct BookingDetails {     let departureCity: String = "Санкт-Петербург"     let countryCity: String = "Египет, Хургада"     let dates: String = "19.09.2023 - 27.09.2023"     let nights: String = "7 ночей"     let hotelName: String = "Steigenberger Makadi"     let roomType: String = "Стандартный с видом на бассейн или сад"     let mealPlan: String = "Все включено" }  // MARK: - Main Booking Info View  struct BookingInfoView: View {     // --- State Variables ---     @State private var phoneNumber: String = "" // Stores the RAW digits (max 10)   //  @State private var displayedPhoneNumber: String = "+7 " // What the user sees/edits     @State private var email: String = ""     @State private var isEmailValid: Bool = true // Assume valid initially or until typed      // --- Data ---     let hotelInfo = HotelBookingInfo()     let bookingDetails = BookingDetails()      // --- Environment ---     @Environment(\.presentationMode) var presentationMode      var body: some View {         NavigationView {             ScrollView {                 VStack(spacing: 8) { // Spacing between blocks                     HotelInfoBlock(info: hotelInfo)                     BookingDetailsBlock(details: bookingDetails)                     BuyerInfoBlock(                         phoneNumber: $phoneNumber,                         email: $email,                         isEmailValid: $isEmailValid                     )                      Spacer() // Pushes content up if screen is tall                 }                 .padding(.horizontal) // Padding for the whole content stack                 .padding(.top)      // Padding from the navigation bar             }             .background(Color(UIColor.systemGray6)) // Background for the whole screen             .navigationTitle("Бронирование")             .navigationBarTitleDisplayMode(.inline)             .toolbar {                 ToolbarItem(placement: .navigationBarLeading) {                     Button {                         presentationMode.wrappedValue.dismiss()                     } label: {                         Image(systemName: "chevron.left")                     }                     .tint(.primary) // Ensure back button has default color                 }             }         }     } }  // MARK: - Block Views  struct HotelInfoBlock: View {     let info: HotelBookingInfo      var body: some View {         VStack(alignment: .leading, spacing: 8) {             HStack(spacing: 4) {                 Image(systemName: "star.fill")                 Text("\(info.ratingValue) \(info.ratingName)")             }             .font(.system(size: 16, weight: .medium))             .foregroundColor(.orange)             .padding(.horizontal, 10)             .padding(.vertical, 5)             .background(Color.orange.opacity(0.15))             .cornerRadius(5)              Text(info.hotelName)                 .font(.system(size: 22, weight: .medium))              Text(info.hotelAddress)                 .font(.system(size: 14, weight: .regular))                 .foregroundColor(.blue) // Link-like appearance         }         .padding()         .frame(maxWidth: .infinity, alignment: .leading) // Take full width         .background(Color.white)         .cornerRadius(12)     } }  struct BookingDetailsBlock: View {     let details: BookingDetails      var body: some View {         VStack(alignment: .leading, spacing: 16) { // Increased spacing between rows             BookingDetailRow(label: "Вылет из", value: details.departureCity)             BookingDetailRow(label: "Страна, город", value: details.countryCity)             BookingDetailRow(label: "Даты", value: details.dates)             BookingDetailRow(label: "Кол-во ночей", value: details.nights)             BookingDetailRow(label: "Отель", value: details.hotelName)             BookingDetailRow(label: "Номер", value: details.roomType)             BookingDetailRow(label: "Питание", value: details.mealPlan)         }         .padding()         .frame(maxWidth: .infinity, alignment: .leading)         .background(Color.white)         .cornerRadius(12)     } }  // Helper for Booking Details rows struct BookingDetailRow: View {     let label: String     let value: String      var body: some View {         HStack(alignment: .top) { // Align top for potentially multi-line values             Text(label)                 .font(.system(size: 16))                 .foregroundColor(.secondary)                 .frame(width: 110, alignment: .leading) // Fixed width for labels              Text(value)                 .font(.system(size: 16))                 .foregroundColor(.primary)                 .frame(maxWidth: .infinity, alignment: .leading) // Take remaining space         }     } }  struct BuyerInfoBlock: View {     @Binding var phoneNumber: String // Raw digits   //  @Binding var displayedPhoneNumber: String // Formatted for display/edit     @Binding var email: String     @Binding var isEmailValid: Bool          @State var isValid: Bool = false     let phoneRegex = try! NSRegularExpression(pattern: #"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$"#)      var body: some View {         VStack(alignment: .leading, spacing: 20) { // Space between title and fields             Text("Информация о покупателе")                 .font(.system(size: 22, weight: .medium))              VStack(alignment: .leading, spacing: 8) { // Space within the fields group                 Text("Номер телефона")                     .font(.system(size: 12))                     .foregroundColor(.secondary)                     .padding(.leading, 12) // Indent label slightly                  TextField("", text: $phoneNumber, prompt: Text("+7 (***) *** - ** - **").foregroundColor(.gray))                     .keyboardType(.numberPad)                     .onReceive(Just(phoneNumber)) { newValue in                         if !phoneNumber.isEmpty {                             phoneNumber = formatPhoneNumber(newValue)                             validatePhoneNumber(newValue)                         }                     }                     .onAppear{                         UITextField.appearance().clearButtonMode = .whileEditing                     }                     .padding()                     .background(Color(.systemGray6))                     .cornerRadius(8)                                  if !isValid {                     Text("Invalid format. Example: +7 (901) 555-66-77")                         .foregroundColor(.red)                         .font(.caption)                 }                  Text("Почта")                     .font(.system(size: 12))                     .foregroundColor(.secondary)                     .padding(.leading, 12)                  TextField("example@mail.ru", text: $email)                     .keyboardType(.emailAddress)                     .autocapitalization(.none)                     .disableAutocorrection(true)                     .padding(.horizontal, 12)                     .padding(.vertical, 10)                     .background(isEmailValid ? Color(UIColor.systemGray6) : Color.red.opacity(0.15)) // Visual validation feedback                     .cornerRadius(10)                     .overlay( // Optional: Add border for invalid state                         RoundedRectangle(cornerRadius: 10)                        .stroke(isEmailValid ? Color.clear : Color.red,                                     lineWidth: 1)                     )                     .onChange(of: email) { _, newValue in                         // Validate only if email is not empty                         if !newValue.isEmpty {                              isEmailValid = isValidEmail(newValue)                         } else {                             isEmailValid = true // Consider empty as valid or neutral                         }                     }             }              Text("Эти данные никому не передаются. После оплаты мы вышли чек на указанный вами номер и почту")                 .font(.system(size: 14))                 .foregroundColor(.secondary)          }         .padding()         .frame(maxWidth: .infinity, alignment: .leading)         .background(Color.white)         .cornerRadius(12)     }      // MARK: Phone Masking Logic     // Formats the input string to the template: +7 (XXX) XXX-XX-XX.     private  func formatPhoneNumber(_ number: String) -> String {             // Remove any non-digit characters.             let digitsOnly = number.filter { $0.isNumber }             var cleanedNumber = digitsOnly                          // If the first digit is 7 or 8, remove it.             if let first = cleanedNumber.first, first == "7" || first == "8" {                 cleanedNumber.removeFirst()             }                          // Limit to 10 digits.             if cleanedNumber.count > 10 {                 cleanedNumber = String(cleanedNumber.prefix(10))             }                          // Start with the fixed +7 prefix.             var formatted = "+7 "                          // Create the area code part.             if !cleanedNumber.isEmpty {                 formatted += "("                 let areaCodeEnd = min(3, cleanedNumber.count)                 let areaCode = cleanedNumber.prefix(areaCodeEnd)                 formatted += areaCode                 if areaCode.count == 3 {                     formatted += ") "                 }             }                          // Next 3 digits.             if cleanedNumber.count > 3 {                 let startIndex = cleanedNumber.index(cleanedNumber.startIndex, offsetBy: 3)                 let endIndex = cleanedNumber.index(startIndex, offsetBy: min(3, cleanedNumber.count - 3))                 let firstThree = cleanedNumber[startIndex..<endIndex]                 formatted += firstThree                 if firstThree.count == 3 {                     formatted += "-"                 }             }                          // Next 2 digits.             if cleanedNumber.count > 6 {                 let startIndex = cleanedNumber.index(cleanedNumber.startIndex, offsetBy: 6)                 let endIndex = cleanedNumber.index(startIndex, offsetBy: min(2, cleanedNumber.count - 6))                 let nextTwo = cleanedNumber[startIndex..<endIndex]                 formatted += nextTwo                 if nextTwo.count == 2 {                     formatted += "-"                 }             }                          // Last 2 digits.             if cleanedNumber.count > 8 {                 let startIndex = cleanedNumber.index(cleanedNumber.startIndex, offsetBy: 8)                 let endIndex = cleanedNumber.index(startIndex, offsetBy: min(2, cleanedNumber.count - 8))                 let lastTwo = cleanedNumber[startIndex..<endIndex]                 formatted += lastTwo             }                          return formatted         }          private func validatePhoneNumber(_ number: String) {         let range = NSRange(location: 0, length: number.utf16.count)         // Check if the entire string matches the regular expression         isValid = phoneRegex.firstMatch(in: number, options: [], range: range) != nil     }       // MARK: Email Validation Logic      private func isValidEmail(_ email: String) -> Bool {          // Simple regex for basic email format validation          let emailFormat = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"          let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailFormat)          return emailPredicate.evaluate(with: email)      } }   // MARK: - Preview  #Preview {         BookingInfoView() }

Ключевые пояснения:

  1. Управление состоянием: переменные @State (phoneNumber, email, isEmailValid) используются в BookingInfoView и передаются как @Bindings в BuyerInfoBlock, где они фактически используются в TextFields.

  2. Маска для телефонного номера (formatPhoneNumber):

    • Она активируется .onReceive(Just(phoneNumber)).

    • Модификатор .onReceive отслеживает изменения состояния phoneNumber: всякий раз, когда phoneNumber изменяется, вызывается функция форматирования formatPhoneNumber), и phoneNumber обновляется замаскированной версией.

    • TextField,формирующий номер телефона по мере ввода пользователем, следует шаблону +7 (XXX) XXX-XX-XX. 

    • В этом примере пользователь должен вводить цифры без начальных 7 или 8, и код удаляет любые такие цифры, если они присутствуют в начале ввода.

    • formatPhoneNumber извлекает только числовые символы, ограничивает запись 10 цифрами (после удаления начальных 7 или 8), а затем применяет маску.

  3. Проверка электронной почты (isValidEmail):

    • Простая вспомогательная функция isValidEmail(_ email: String) использует NSPredicate с общим регулярным выражением электронной почты.

    • .onChange(of: email) запускает проверку.

    • Цвет фона (и необязательная граница) текстового поля электронной почты изменяется в зависимости от состояния isEmailValid, обеспечивая визуальную обратную связь.

    • Проверка происходит только в том случае, если поле не пустое.

  4. Навигация: стандартный NavigationView со встроенным заголовком и настраиваемой кнопкой «Back» на панели инструментов.

Теперь у вас есть функциональное SwiftUI View, которое повторяет дизайн из Figma и включает заданное поведение полей ввода. Не забудьте заменить данные заполнителя (HotelBookingInfo, BookingDetails) на ваш фактический источник данных.

Вот как функционирует BookingInfoView для бронирования номера:

Бронирование номера отеля (часть 2)

Прототип экрана из Figma:

Делаем запрос Gemini 2.5 Experimental 03-25

Ответ Gemini 2.5 Experimental

Хорошо, давайте создадим SwiftUI View для ввода туристической информации о туристах и отображения окончательноой разбивки цен, включая сворачиваемые разделы и возможность динамического добавления туристов

код TouristInfoView
import SwiftUI  // MARK: - Data Models  struct Tourist: Identifiable {     let id = UUID() // Necessary for ForEach     var isExpanded: Bool = true // State for collapsing/expanding      // Form fields     var firstName: String = ""     var lastName: String = ""     var dateOfBirth: String = "" // Could use Date type later     var citizenship: String = ""     var passportNumber: String = ""     var passportExpiry: String = "" // Could use Date type later }  struct PricingInfo {     let tourPrice: Int     let fuelSurcharge: Int     let serviceCharge: Int      var totalPrice: Int {         tourPrice + fuelSurcharge + serviceCharge     } }  // MARK: - Main View  struct TouristInfoView: View {     // --- State Variables ---     @State private var tourists: [Tourist] = [Tourist()] // Start with one tourist     @State private var pricing = PricingInfo(         tourPrice: 186600,         fuelSurcharge: 9300,         serviceCharge: 2136     )      var body: some View {              VStack {             ScrollView {                 VStack(spacing: 8) { // Spacing between blocks                      // --- Tourist Information Block ---                     TouristListBlock(tourists: $tourists)                      // --- Add Tourist Button ---                     AddTouristButton {                         addNewTourist()                     }                      // --- Price Summary Block ---                     PriceSummaryBlock(pricing: pricing)                     PaymentButton(totalAmount: pricing.totalPrice) {                         // Action for payment                         print("Proceeding to pay \(formattedPrice(pricing.totalPrice))")                     }                  }                 .padding(.horizontal) // Padding for the whole content stack                 .padding(.top)             }             .background(Color(UIColor.systemGray6).ignoresSafeArea()) // Background for the whole screen         }     }      // --- Helper Functions ---      private func addNewTourist() {         withAnimation { // Animate the addition              tourists.append(Tourist())         }     }       private func formattedPrice(_ price: Int) -> String {         let formatter = NumberFormatter()         formatter.numberStyle = .decimal         formatter.groupingSeparator = " " // Use space as separator         return (formatter.string(from: NSNumber(value: price)) ?? "\(price)") + " ₽"     } }  // MARK: - Child Views / Blocks  struct TouristListBlock: View {     @Binding var tourists: [Tourist]      var body: some View {         VStack(spacing: 8) { // Consistent spacing              ForEach($tourists) { $tourist in // Use $ for bindings                 // Find the index for displaying the correct number                  if let index = tourists.firstIndex(where: { $0.id == tourist.id }) {                     TouristEntryView(                         tourist: $tourist,                         touristNumber: index + 1 // Pass the 1-based index                     )                  }             }         }         // No background/cornerRadius here, apply it to TouristEntryView itself     } }  struct TouristEntryView: View {     @Binding var tourist: Tourist     let touristNumber: Int      // Helper to get ordinal string ("Первый", "Второй", etc.)     private func ordinal(number: Int) -> String {         // Basic implementation, expand for more numbers if needed         switch number {         case 1: return "Первый"         case 2: return "Второй"         case 3: return "Третий"         case 4: return "Четвертый"         case 5: return "Пятый"         default: return "\(number)-й"         }     }      var body: some View {         VStack(alignment: .leading, spacing: 0) { // No spacing for header/content separation             // --- Header ---             HStack {                 Text("\(ordinal(number: touristNumber)) турист")                     .font(.system(size: 22, weight: .medium))                 Spacer()                 Button {                     withAnimation(.easeInOut(duration: 0.2)) { // Animate expand/collapse                         tourist.isExpanded.toggle()                     }                 } label: {                     Image(systemName: tourist.isExpanded ? "chevron.up" : "chevron.down")                         .foregroundColor(.blue)                         .padding(8) // Increase tappable area                         .background(Color.blue.opacity(0.1))                         .clipShape(RoundedRectangle(cornerRadius: 6))                 }             }             .padding() // Padding for the header itself              // --- Form Fields (Conditional) ---             if tourist.isExpanded {                 VStack { // Spacing between fields                     StylizedTextField(placeholder: "Имя",                               text: $tourist.firstName)                     StylizedTextField(placeholder: "Фамилия",                               text: $tourist.lastName)                     StylizedTextField( placeholder: "Дата рождения",                               text: $tourist.dateOfBirth)                     StylizedTextField( placeholder: "Гражданство",                               text: $tourist.citizenship)                     StylizedTextField(placeholder: "Номер загранпаспорта",                               text: $tourist.passportNumber)                     StylizedTextField(                         placeholder: "Срок действия загранпаспорта",                          text: $tourist.passportExpiry)                 }                 .padding(.horizontal) // Padding for the form fields container                 .padding(.bottom)    // Add padding at the bottom of the form                 // Smooth transition for appearing fields                 .transition(.opacity.combined(with: .move(edge: .top)))             }         }         .background(Color.white)         .cornerRadius(12)     } }  // Reusable TextField with placeholder label struct StylizedTextField: View {     let placeholder: String     @Binding var text: String      var body: some View {             TextField(placeholder, text: $text)                 .padding()                 .background(Color.gray.opacity(0.1))                 .cornerRadius(10)                 .overlay (alignment: .top) {                 if !text.isEmpty {                     Text(placeholder)                         .font(.system(size: 12))                         .foregroundColor(.gray)                         .padding(.leading, 12) // Indent title slightly                         .padding(.bottom, -2) // Pull textfield up a bit                         .zIndex(1) // Ensure title is above TextField background                 }             }     } }  struct AddTouristButton: View {     let action: () -> Void      var body: some View {         HStack {             Text("Добавить туриста")                 .font(.system(size: 22, weight: .medium))             Spacer()             Button(action: action) {                 Image(systemName: "plus")                     .foregroundColor(.white)                     .padding()                     .background(Color.blue)                     .clipShape(RoundedRectangle(cornerRadius: 6))             }         }         .padding()         .background(Color.white)         .cornerRadius(12)     } }  struct PriceSummaryBlock: View {     let pricing: PricingInfo      private func formattedPrice(_ price: Int) -> String {         // Duplicated formatting logic for self-containment, could be centralized         let formatter = NumberFormatter()         formatter.numberStyle = .decimal         formatter.groupingSeparator = " "         return (formatter.string(from: NSNumber(value: price)) ?? "\(price)") + " ₽"     }      var body: some View {         VStack(alignment: .leading, spacing: 16) {             PriceRow(label: "Тур", amount: pricing.tourPrice)             PriceRow(label: "Топливный сбор", amount: pricing.fuelSurcharge)             PriceRow(label: "Сервисный сбор", amount: pricing.serviceCharge)             PriceRow(label: "К оплате", amount: pricing.totalPrice,                                                              isTotal: true)         }         .padding()         .background(Color.white)         .cornerRadius(12)     } }  // Helper for rows in the price summary struct PriceRow: View {     let label: String     let amount: Int     var isTotal: Bool = false      private func formattedPrice(_ price: Int) -> String {          let formatter = NumberFormatter()          formatter.numberStyle = .decimal          formatter.groupingSeparator = " "          return (formatter.string(from: NSNumber(value: price)) ?? "\(price)") + " ₽"      }      var body: some View {         HStack {             Text(label)                 .font(.system(size: 16))                 .foregroundColor(.secondary)              Spacer()              Text(formattedPrice(amount))             .font(.system(size: 16, weight: isTotal ? .semibold : .regular))             .foregroundColor(isTotal ? .blue : .primary) // Highlight total price         }     } }   struct PaymentButton: View {     let totalAmount: Int     let action: () -> Void      private func formattedPrice(_ price: Int) -> String {          let formatter = NumberFormatter()          formatter.numberStyle = .decimal          formatter.groupingSeparator = " "          return (formatter.string(from: NSNumber(value: price)) ?? "\(price)") + " ₽"      }      var body: some View {         Button(action: action) {             Text("Оплатить \(formattedPrice(totalAmount))")                 .font(.system(size: 16, weight: .medium))                 .foregroundColor(.white)                 .frame(maxWidth: .infinity)                 .padding()                 .background(Color.blue)                 .cornerRadius(15)         }         .padding(.horizontal)         .padding(.vertical, 8) // Add some vertical padding         .background( // Add a white background behind the button area             Color.white               .ignoresSafeArea(edges: .bottom) // Extend white to screen bottom edge               .shadow(radius: 1) // Optional subtle shadow above button         )     } }   // MARK: - Preview  #Preview {         TouristInfoView()   }

Объяснение:

  1. Модели данных (Tourist, PricingInfo): определяют структуры для хранения необходимых данных.

  2. TouristInfoView (главное View):

    • Содержит @State переменные var: массив туристов tourists: [Tourist] и информацию о ценах pricing: PricingInfo.

    • Использует ScrollView для размещения контента.

    • Организует размещение основных блоков (TouristListBlock, AddTouristButton, PriceSummaryBlock, PaymentButton) в VStack.

    • Функция addNewTourist() добавляет нового туриста Tourist по умолчанию в массив.

  3. TouristListBlock:

    • Принимает @Bindingмассив туристов tourists и использует ForEach для итерации по привязке $tourists

    • Создает TouristEntryView для каждого туриста с номером (index + 1).

  4. TouristEntryView

    • Представляет сворачиваемую карточку для одного туриста.

    • Принимает @Bindingдля одного туриста Tourist.

    • Имеет HStack с номером туриста прописью (используя ordinal(number: Int) ) и кнопку развернуть/свернуть.

    • Появление/исчезновение анимируется с помощью .transition.

  5. StylizedTextField: Многоразовое вспомогательное View для создания определенного стиля текстового поля.

  6. AddTouristButton: Простое View для строки «Добавить туриста» с кнопкой «+».

  7. PriceSummaryBlock и PriceRow: View для отображения разбивки цен, аналогично предыдущим примерам, с правильным форматированием чисел. В итоговой строке используется немного более жирный текст и синий цвет.

  8. PaymentButton: последняя синяя кнопка внизу, отображающая общую цену и запускающая действие action. Она находится во вставке безопасной области (safe area inset).

Эта версия напрямую реализует запрошенную функциональность, включая возможность добавлять туристов, разворачивать/сворачивать их разделы и вводить их данные с правильной привязкой данных для редактирования.

Вот как выглядит TouruistInfoView для ввода туристов:

Вот как функционирует TouruistInfoView для ввода туристов:

Объединяем всё вместе :

Заключение

Gemini 2.5 Experimental воспроизводит в SwiftUI с поразительной точностью стили текста и функциональные возможности макетов, подготовленных дизайнерами в Figma. Особенно это касается разработки русскоязычных UI.

Выдаёт полноценный изобретательный SwiftUI код, демонстрируя высокий потенциал Gemini 2.5 в преобразовании Figma-макетов в рабочий iOS-код.

Код находится на Github.


ссылка на оригинал статьи https://habr.com/ru/articles/896498/


Комментарии

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

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