Мне случайно попалось задание с собеседования 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 структуру для этого приложения с информацией об отеле на основе ваших снимков экрана.
Мы создадим:
-
Модели данных: для представления информации об отеле и номере.
-
HotelDetailView: главный экран, на котором отображаются сведения об отеле, карусель изображений, описание и ссылки на другие разделы.
-
AmenitiesView:
View
-заполнитель для сведений об удобствах. -
RoomSelectionView: экран для отображения различных категорий номеров с их подробностями и изображениями.
-
RoomCardView: повторно используемое
View
для отображения одной карточки категории номера. -
Вспомогательные 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:
-
Точность воспроизведения макетов Figma в SwiftUI:
— Экран отеля и категорий номеров воссозданы на SwiftUI с сохранением стилей текста, функционала и локализации (русский язык). -
Автономная генерация данных:
— Gemini 2.5 самостоятельно сформировала структуру тестовых данных без внешних API. -
Архитектура кода:
— Лаконичный SwiftUI код с TabView для «карусели» фотографий и повторно используемыми компонентами: SectionView, ImageCarouselView.
— Инновационный FlexibleGridView для адаптивного отображения элементов удобств. -
Ресурсы:
— Тестовые изображения интегрированы через 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() }
Ключевые пояснения:
-
Управление состоянием: переменные
@State
(phoneNumber
,email
,isEmailValid
) используются вBookingInfoView
и передаются как@Bindings
вBuyerInfoBlock
, где они фактически используются вTextFields
. -
Маска для телефонного номера (
formatPhoneNumber
):-
Она активируется .
onReceive(Just(phoneNumber))
. -
Модификатор
.onReceive
отслеживает изменения состоянияphoneNumber
: всякий раз, когдаphoneNumber
изменяется, вызывается функция форматированияformatPhoneNumber
), иphoneNumber
обновляется замаскированной версией. -
TextField
,формирующий номер телефона по мере ввода пользователем, следует шаблону +7 (XXX) XXX-XX-XX. -
В этом примере пользователь должен вводить цифры без начальных 7 или 8, и код удаляет любые такие цифры, если они присутствуют в начале ввода.
-
formatPhoneNumber
извлекает только числовые символы, ограничивает запись 10 цифрами (после удаления начальных 7 или 8), а затем применяет маску.
-
-
Проверка электронной почты (
isValidEmail
):-
Простая вспомогательная функция
isValidEmail(_ email: String)
используетNSPredicate
с общим регулярным выражением электронной почты. -
.onChange(of: email) з
апускает проверку. -
Цвет фона (и необязательная граница) текстового поля электронной почты изменяется в зависимости от состояния
isEmailValid
, обеспечивая визуальную обратную связь. -
Проверка происходит только в том случае, если поле не пустое.
-
-
Навигация: стандартный
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() }
Объяснение:
-
Модели данных (
Tourist
,PricingInfo
): определяют структуры для хранения необходимых данных. -
TouristInfoView
(главноеView
):-
Содержит
@State
переменныеvar
: массив туристовtourists: [Tourist]
и информацию о ценахpricing: PricingInfo
. -
Использует
ScrollView
для размещения контента. -
Организует размещение основных блоков (
TouristListBlock
,AddTouristButton
,PriceSummaryBlock
,PaymentButton
) вVStack
. -
Функция
addNewTourist()
добавляет нового туристаTourist
по умолчанию в массив.
-
-
TouristListBlock
:-
Принимает
@Binding
массив туристовtourists
и используетForEach
для итерации по привязке$tourists
-
Создает
TouristEntryView
для каждого туриста с номером(index + 1).
-
-
TouristEntryView
:-
Представляет сворачиваемую карточку для одного туриста.
-
Принимает
@Binding
для одного туристаTourist
. -
Имеет
HStack
с номером туриста прописью (используяordinal(number: Int
) ) и кнопку развернуть/свернуть. -
Появление/исчезновение анимируется с помощью
.transition
.
-
-
StylizedTextField
: Многоразовое вспомогательноеView
для создания определенного стиля текстового поля. -
AddTouristButton
: ПростоеView
для строки «Добавить туриста» с кнопкой «+». -
PriceSummaryBlock
иPriceRow
:View
для отображения разбивки цен, аналогично предыдущим примерам, с правильным форматированием чисел. В итоговой строке используется немного более жирный текст и синий цвет. -
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/
Добавить комментарий