Мне хотелось посмотреть, как работает ИИ Редактор кода Cursor AI на примере создания iOS приложения с выборкой данных с ресурса, который не требует API key и платной подписки. И этим ресурсом оказались публичные фотографии с Flickr.com.

Задача

Создать UI iOS приложения со строкой поиска вверху и сеткой Grid под ней для отображения миниатюр фотографий наподобие:

Пользователь должен иметь возможность вводить текст в строку поиска и видеть набор фотографий, теги которых tags соответствуют строке поиска. Строка поиска может содержать одно слово (например, «rose») или разделенные пробелами слова(например, «forest bird» (лес птица)).

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

  • Список фотографий извлекается с помощью API из Flickr типаhttps://api.flickr.com/services/feeds/photos_public.gne?format=json&nojsoncallback=1&tags=porcupine (замените слово“porcupine” на то, которое ввел пользователь).

  • При выполнении поиска отображается индикатор хода выполнения, не блокируя UI.

  • При нажатии на изображение должно быть показано View с подробной информации о фотографии.

    Окончательный вариант iOS приложения PhotomaniaCursor находится на Github.com

    Используемые технологии:

    • Swift, SwiftUI

    • Макрос @Observable

    • async / await

    Настройка: Mac, Xcode и Cursor AI

    Вам нужен Mac — любой: M ac Studio, MacBook, iMac. Вам нужно будет загрузить Xcode из App Store.

    Это приложение Apple, и оно поможет вам писать код. Но вы не будете писать ни единой строки кода, Cursor AI сделает это за вас, но Xcode вам понадобится для создания iOS приложения и предварительного просмотра результатов Preview
    Загрузите Cursor AI.

    Запустите Xcode и Cursor AI одновременно:

    Первое, что вам нужно сделать, - это создать новый проект в Xcode:

    Убедитесь, что вы создаете iOS приложение:

    … и назовите его PhotomaniaCursor:

    Просто нажмите Next (“Далее”), и вам будет предложено определить место для хранения файлов приложения? Я выберу свой рабочий стол desktop, потому что это просто.

    Я нажму Create (“Создать”), и вы увидите, что там создана папка PhotomaniaCursor.

    Xcode создал все начальные файлы для моего приложения, и на предварительном просмотре Preview вы видите Hello World! Он дает вам пример кода, который отображает Hello World! на экране iPhone, что довольно круто. Это показывает, как легко начать создавать iOS приложение, но мы не собираемся писать код. 

    Мы собираемся использовать Cursor AI, чтобы сделать это. Запускаем Cursor AI, и если это первый раз, то открываем папку с нашим Xcode проектом. 

И, конечно, если я зайду на свой рабочий стол desktop, то там найду папку PhotomaniaCursor.

Нажимает на Open («Открыть») и все файлы подсоединяют к Cursor AI.

Если я их открою, то вы увидите там все, что у меня было в Xcode. Всё это теперь привязано к Cursor AI.

Начнем с того, что запустим Composer с помощью Shift + Cmd + I. Вы получите  Untitled Composer

Вы можете использовать его, чтобы начать писать код. Но, в первую очередь, я хочу убедиться, что следую лучшим рекомендациям Apple при создании моего iOS-приложения. Вы можете научить Cursor AI лучшим практикам Apple, чтобы ваше приложение стало идеальным. Как вы это делаете?

Введите символ @  в Cursor  Composer:

… и начните вводить @Docs:

Вы увидите  @Docs выглядит так.

Теперь вы видите, что я уже обучала  Cursor AI некоторым предыдущим документам, но я могу добавить новый документ здесь:

Я могу добавить веб-страницу. У меня на Chrome открыто несколько разных веб-сайтов.
Во-первых, у меня есть документация Swift.

Итак, я скопирую URL-адрес Apple Swift, и вставлю его сюда:

Дадим этому документу имя Apple Swift и кликнем «Confirm» («Подтвердить»):

И он начнет обучать Cursor AI языку программирования Swift.

Теперь я хочу обучить Cursor AI еще нескольким документам,включая «UI design Do's and Don'ts» («Советы по дизайну пользовательского интерфейса») от Apple:

Чтобы Cursor АI также следовал этим рекомендациям.

До сих пор мы обучались на документации @Apple Swift. Теперь у нас «UI design Do's and Don'ts» («Советы по дизайну пользовательского интерфейса Apple»), то есть @Apple UI Design Tips:

Cursor АI будет ссылаться на них при написании кода. Итак, мы не используем старые данные для обучения. Это последние новости на сайте Apple. Еще один тег мы сделаем перед тем, как вставить описание нашего приложения. 
И это @Codebase:

Codebase очень важна, потому что у нас есть существующие файлы, созданные Xcode, и мы хотим, чтобы Composer видел и редактировал их, создавая наше приложение:

Описание приложения для Cursor AI

Теперь, когда все отмечено, я могу добавить описание приложения. Кстати, Cursor AI понимает только английский язык

Я хотела бы создать приложение Flickr Photos для iOS
Оно должно делать следующее.

  1. Взять фотографии Photos c https://api.flickr.com/services/feeds/photos_public.gne?format=json&nojsoncallback=1

  2. Показать фотографии Photos с использованием LazyVGrid и использовать .searchable для поиска фотографий.

  3. Добавить PhotoDetailView для подробной информации о фотографии. 

  4. Использовать NavigationStack.

Это все, что мне нужно сделать, чтобы начать создавать свое приложение для iOS. 
Я нажимаю Enter, и Cursor Composer приступает к работе. 

Вы увидите, что он пишет код прямо у меня на глазах. Я пока не знаю, что делает этот код. Он просто создает мое приложение в соответствии с лучшими Apple принципами дизайна UI  @Apple UI Design Tips

Давайте кликнем Accept All, то есть мы принимаем весь созданный Cursor AI код:

В Xcode мы сразу увидим, что все созданные Cursor AI файлы добавлены, и предварительный просмотр Previewобновлен:

Cursor AI создал новые файлы прямо в нашем проекте Xcode: FlickrPhotoFlickrViewModelPhotoDetailView и PhotoGridItem. В дальнейшем нам не придется копировать или обновлять их вручную, Composer Cursor AI все будет делать в этом проекте автоматически.

И приложение работает. Да, фотографии выбраны c Flickr.com и размещены в сетке LazyVGid, которая не идеальна и нуждается в настройке, поисковая строка также присутствует на UI. Но если я попытаюсь начать набирать текст в поисковой строке для выбора фотографий, то посмотрите, что будет происходить:

Версия 1

То есть выборка фотографий идет не с сервера Flickr.com, a из того списка, который есть на экране.

Строка поиска в качестве tags для URL

Я прошу Cursor AI  о том, чтобы строка поиска searchText использовалась при формировании запроса к  Flickr.com в качестве tags. Вот моя подсказка (Prompt):

Для удобства я переведу эту подсказку (Prompt) и ответ Cursor AI на русский язык, и далее будут предоставлять только ответы Cursor AI на русском языке.

Используй  searchText в качестве tags в URL для поиска фотографий в коде: 

if !searchText.isEmpty {
      // Convert search text to comma-separated tags and properly encode for URL
          let tags = searchText
              .components(separatedBy: " ")
              .filter { !$0.isEmpty }
              .joined(separator: ",")
         
          if let encodedTags = tags.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
              urlString += "&tags=\(encodedTags)"
          }
      }

Я помогу вам изменить FlickrViewModel, чтобы включить поиск на основе тегов tags в URL API. Вот обновленная версия FlickrViewModel

И нам следует обновить ContentView, чтобы запустить новый поиск при изменении searchText:

Ключевые изменения:

  1. Удалена локальная фильтрация в filteredPhotos, так как теперь мы используем API для выборки.

  2. Добавлен поиск на основе тегов tags в конструкцию URL

  3. Добавлен модификатор .onChange для запуска нового поиска при изменении searchText

  4. Выделен базовый URL baseURL для лучшей читабельности

  5. Добавлено правильное кодирование URL для тегов tags.

Эта реализация теперь будет искать фотографии с помощью системы тегов Flickr, которая более эффективна, чем фильтрация на стороне клиента, и будет возвращать более релевантные результаты.

Если все объяснения нас устраивают, опять кликаем Accept All.

Переключаемся на Xcode. И опять, нам не пришлось копировать какие-либо файлы или обновлять их вручную, Cursor AI все сделал автоматически и предварительный просмотр Preview обновлен.

Поиск фотографий по тегам tags, задаваемым в поисковой строке searchText  теперь работает правильно.

Версия 2

Однако сетка Grid все еще выглядит не совсем хорошо. Кроме того, в подробном фото есть непонятный текст с описанием description

Индикатор загрузки и isLoading

Давайте уберем из PhotoDetailView описание description и добавим использование индикатора isLoading из FlickrViewModel в ContentView:

Убери  Text(photo.description) из PhotoDetailView и используй isLoading из FlickrViewModel в ContentView

Я помогу вам изменить оба View
Сначала давайте обновим PhotoDetailView, чтобы удалить описание фото description:

PhotoDetailView.swift 2 / 4
import SwiftUI

struct PhotoDetailView: View {
    let photo: FlickrPhoto
   
    var body: some View {
        ScrollView {
           VStack(alignment: .leading, spacing: 16) {
              AsyncImage(url: URL(string: photo.media.originalURL)) { image in
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                } placeholder: {
                    ProgressView()
                }
                .frame(maxWidth: .infinity)
               
                VStack(alignment: .leading, spacing: 8) {
                    Text(photo.title)
                        .font(.title2)
                        .bold()
                   
                    Text("By \(photo.author)")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                   
                    Text("Taken on \(photo.dateTaken)")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
                .padding()
            }
        }
        .navigationBarTitleDisplayMode(.inline)
    }
}

Теперь давайте обновим ContentView, чтобы лучше обрабатывать состояние загрузки фотографий:

ContentView.swift 2 / 8
struct ContentView: View {
  @StateObject private var viewModel = FlickrViewModel()
 
  let columns = [
      GridItem(.adaptive(minimum: 150), spacing: 20)
  ]
 
  var body: some View {
      NavigationStack {
          ZStack {
              ScrollView {
                LazyVGrid(columns: columns, spacing: 20) {
                    ForEach(viewModel.photos) { photo in
                        NavigationLink(destination: 
                                   PhotoDetailView(photo: photo)) {
                              PhotoGridItem(photo: photo)
                        }
                    }
                }
                  .padding()
              }
             
              if viewModel.isLoading {
                  ProgressView()
                      .scaleEffect(1.5)
                      .frame(maxWidth: .infinity, 
                             maxHeight: .infinity)
                      .background(Color.black.opacity(0.1))
              }
          }
          .navigationTitle("Flickr Photos")
          .searchable(text: $viewModel.searchText, 
                                          prompt: "Search photos")
         .onChange(of: viewModel.searchText) { oldValue, newValue in
              Task {
                  await viewModel.fetchPhotos()
              }
          }
          .alert("Error", isPresented: 
                         .constant(viewModel.errorMessage != nil)) {
              Button("OK") {
                  viewModel.errorMessage = nil
              }
          } message: {
              Text(viewModel.errorMessage ?? "")
          }
      }
      .task {
          await viewModel.fetchPhotos()
      }
  }
}

Ключевые изменения:

Удален Text с описанием photo.description из PhotoDetailView 
Улучшено состояние загрузки в ContentView за счет:

  • Перемещения индикатора загрузки в ZStack

  • Добавления полупрозрачного фона

  • Более заметного показа индикатора загрузки с помощью scaleEffect

  • Центрирования его в View

Теперь индикатор загрузки будет более заметным и предоставит пользователям лучшую обратную связь во время загрузки фотографий.

Если все объяснения нас устраивают, опять кликаем Accept All.

В Xcode мы видим, что предварительный просмотр Preview обновлен:

Версия 3

Поиск фотографий по тегам tags, задаваемым в поисковой строке searchText  по-прежнему работает правильно, виден индикатор загрузки фотографий и исчезло описание description в PhotoDetailView
Однако сетка Grid все еще выглядит не совсем хорошо, попробуем её улучшить.

Улучшение сетки LazyVGrid

Продолжаем работать с Cursor AI. Я прошу его улучшить UI и прикладываю screenshot того, что мне не нравится,

LazyVGrid выглядит не очень хорошо. Создай более привлекательный UI 

Я помогу улучшить макет LazyVGrid, чтобы сделать его более визуально привлекательным. Вот обновленный ContentView с улучшенным стилем сетки:

ContentView.swift 3 / 8
struct ContentView: View {
  @StateObject private var viewModel = FlickrViewModel()
 
  // Using 2 columns with fixed size for better proportions
  let columns = [
      GridItem(.flexible(), spacing: 1),
      GridItem(.flexible(), spacing: 1)
  ]
 
  var body: some View {
      NavigationStack {
          ZStack {
              Color.black.opacity(0.1) // Background color for grid
             
              ScrollView {
                  LazyVGrid(columns: columns, spacing: 1) {
                      ForEach(viewModel.photos) { photo in
                          NavigationLink(destination:  
                            PhotoDetailView(photo: photo)) {
                              PhotoGridItem(photo: photo)
                                  .aspectRatio(1, contentMode: .fill) // Square aspect ratio
                          }
                      }
                  }
              }
             
              if viewModel.isLoading {
                  ProgressView()
                      .scaleEffect(1.5)
                      .frame(maxWidth: .infinity, maxHeight: .infinity)
                      .background(Color.black.opacity(0.1))
              }
          }
          .navigationTitle("Flickr Photos")
          .searchable(text: $viewModel.searchText, 
                      prompt: "Search photos")
          .onChange(of: viewModel.searchText) { oldValue, newValue in
              Task {
                  await viewModel.fetchPhotos()
              }
          }
          .alert("Error", isPresented: 
                            .constant(viewModel.errorMessage != nil)) {
              Button("OK") {
                  viewModel.errorMessage = nil
              }
          } message: {
              Text(viewModel.errorMessage ?? "")
          }
      }
      .task {
          await viewModel.fetchPhotos()
      }
  }
}

И давайте обновим PhotoGridItem, чтобы он соответствовал новому стилю сетки:

PhotoGridItem:.swift 2 / 4
import SwiftUI

struct PhotoGridItem: View {
  let photo: FlickrPhoto
 
  var body: some View {
      AsyncImage(url: URL(string: photo.media.m)) { image in
          image
              .resizable()
              .aspectRatio(contentMode: .fill)
      } placeholder: {
          ProgressView()
              .frame(minHeight: 100)
              .background(Color.gray.opacity(0.1))
      }
      .clipped()
      .contentShape(Rectangle())
  }
}

Ключевые улучшения:

  • Изменен на фиксированную сетку из 2 столбцов для согласованности

  • Добавлено квадратное соотношение сторон для элементов сетки

  • Уменьшено расстояние между элементами до 1 point  для более современного вида

  • Добавлен фоновый цвет

  • Упрощен PhotoGridItem для лучшей производительности

  • Элементы сетки теперь полностью заполняют свое пространство

  • Добавлено обрезание для предотвращения переполнения изображения

  • Улучшен внешний вид заполнителя

Это создаст более похожий на Instagram макет сетки, визуально привлекательный и согласованный. Фотографии будут отображаться в квадратном формате с минимальным интервалом, создавая современный вид.

Если все объяснения нас устраивают, опять кликаем Accept All.

В Xcode мы видим, что предварительный просмотр Preview обновлен:

Версия 4

Надо сказать, что какая-то систематизация фотографий в сетке Grid появилась, но все равно фотографии “наезжают” друг на друга. Всё дело в строке кода .aspectRatio(contentMode: .fill), которая позволяет фотографии в сетке LazyVGrid заполнить собой всё пространство. 

Используем .scaledToFill()

Давайте заменим её на .scaledToFill().

В LazyVGrid ипользуй .scaledToFill() вместо .aspectRatio

Я обновлю оба файла PhotoGridItem и  ContentView , чтобы использовать .scaledToFill() для лучшего представления фотографий:

PhotoGridItem.swift 3 / 4
import SwiftUI

struct PhotoGridItem: View {
  let photo: FlickrPhoto
 
  var body: some View {
      AsyncImage(url: URL(string: photo.media.m)) { image in
          image
              .resizable()
              .scaledToFill()
      } placeholder: {
          ProgressView()
              .frame(minHeight: 100)
              .background(Color.gray.opacity(0.1))
      }
      .clipped()
      .contentShape(Rectangle())
  }
}

ContentView.swift 4 / 8
struct ContentView: View {
  @StateObject private var viewModel = FlickrViewModel()
 
  let columns = [
      GridItem(.flexible(), spacing: 1),
      GridItem(.flexible(), spacing: 1)
  ]
 
  var body: some View {
      NavigationStack {
          ZStack {
              Color.black.opacity(0.1)
             
              ScrollView {
                 LazyVGrid(columns: columns, spacing: 1) {
                    ForEach(viewModel.photos) { photo in
                      NavigationLink(destination:  
                                 PhotoDetailView(photo: photo)) {
                          PhotoGridItem(photo: photo)
                          .frame(height: UIScreen.main.bounds.width / 2) // Make height equal to half screen width
                                  .clipped()
                          }
                      }
                  }
              }
             
              if viewModel.isLoading {
                  ProgressView()
                      .scaleEffect(1.5)
                      .frame(maxWidth: .infinity, maxHeight: .infinity)
                      .background(Color.black.opacity(0.1))
              }
          }
          .navigationTitle("Flickr Photos")
          .searchable(text: $viewModel.searchText, prompt: "Search photos")
          .onChange(of: viewModel.searchText) { oldValue, newValue in
              Task {
                  await viewModel.fetchPhotos()
              }
          }
          .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
              Button("OK") {
                  viewModel.errorMessage = nil
              }
          } message: {
              Text(viewModel.errorMessage ?? "")
          }
      }
      .task {
          await viewModel.fetchPhotos()
      }
  }
}

Ключевые изменения:

  • Заменено .aspectRatio на .scaledToFill() в PhotoGridItem

  • Добавлена ​​явная высота height в frame в ContentView на основе ширины экрана screenWidth для квадратных ячеек

  • Сохранено .clipped(), чтобы изображения не переполняли свои контейнеры

  • Удалено .aspectRatio(1, contentMode: .fit) из NavigationLink

Это создаст сетку, более похожую на Instagram, где изображения полностью заполняют свои квадраты, сохраняя при этом соотношение сторон, а любые переполнения обрезаются.

Если все объяснения нас устраивают, опять кликаем Accept All.

В Xcode мы видим, что предварительный просмотр Preview обновлен:

Версия 5

Все замечательно, никто ни на кого не наезжает, но фотографии в одной строке неравноправны - одни больше, другие меньше. Я хотела бы, чтобы они все были квадратные фиксированного размера, например, 108 х 108 и чтобы было три столбца в LazyVGrid, a не два.

Используем 3 столбца в LazyVGrid

Просим об этом Cursor AI:

Используй 3 столбца в  LazyVGrid и frame(width: 108, height : 108) для используй PhotoGridView

Я обновлю оба файла, чтобы использовать 3 столбца с элементами размером 108x108:

ContentView.swift 5 / 8
struct ContentView: View {
  @StateObject private var viewModel = FlickrViewModel()
 
  let columns = [
      GridItem(.adaptive(minimum: 108), spacing: 1),
      GridItem(.adaptive(minimum: 108), spacing: 1),
      GridItem(.adaptive(minimum: 108), spacing: 1)
  ]
 
  var body: some View {
      NavigationStack {
          ZStack {
              Color.black.opacity(0.1)
             
              ScrollView {
                  LazyVGrid(columns: columns, spacing: 1) {
                      ForEach(viewModel.photos) { photo in
                          NavigationLink(destination:   
                                   PhotoDetailView(photo: photo)) {
                              PhotoGridItem(photo: photo)
                          }
                      }
                  }
              }
             
              if viewModel.isLoading {
                  ProgressView()
                      .scaleEffect(1.5)
                      .frame(maxWidth: .infinity,
                             maxHeight: .infinity)
                      .background(Color.black.opacity(0.1))
              }
          }
          .navigationTitle("Flickr Photos")
          .searchable(text: $viewModel.searchText, 
                                        prompt: "Search photos")
      .onChange(of: viewModel.searchText) { oldValue, newValue in
              Task {
                  await viewModel.fetchPhotos()
              }
          }
          .alert("Error", isPresented: 
                          .constant(viewModel.errorMessage != nil)) {
              Button("OK") {
                  viewModel.errorMessage = nil
              }
          } message: {
              Text(viewModel.errorMessage ?? "")
          }
      }
      .task {
          await viewModel.fetchPhotos()
      }
  }
}

PhotoGridItem.swift 4 / 4

import SwiftUI

struct PhotoGridItem: View {
  let photo: FlickrPhoto
 
  var body: some View {
      AsyncImage(url: URL(string: photo.media.m)) { image in
          image
              .resizable()
              .scaledToFill()
              .frame(width: 108, height: 108)
      } placeholder: {
          ProgressView()
              .frame(width: 108, height: 108)
              .background(Color.gray.opacity(0.1))
      }
      .clipped()
      .contentShape(Rectangle())
  }
}

Ключевые изменения:

  • Обновлён размер рамки PhotoGridItem до 108x108

  • Изменена сетка LazyVGrid, чтобы использовать 3 столбца с .adaptive(minimum: 108)

  • Добавлен третий GridItem в массив столбцов columns

  • Сохранен интервал в 1 point между элементами

Это создаст более компактную сетку с 3 столбцами columns квадратных изображений, похожую на макет сетки Instagram.

Если все объяснения нас устраивают, опять кликаем Accept All.

В Xcode мы видим, что предварительный просмотр Preview обновлен:

Версия 6

Ну, наконец-то, получилось так, как мне хочется. В результате вот какие различия в столбцах columns сетки LazyVGrid мы получили:

Было:

let columns = [
      GridItem(.flexible(), spacing: 1),
      GridItem(.flexible(), spacing: 1)
  ]

Стало:

let columns = [
      GridItem(.adaptive(minimum: 108), spacing: 1),
      GridItem(.adaptive(minimum: 108), spacing: 1),
      GridItem(.adaptive(minimum: 108), spacing: 1)
  ]

Добавляем тэги tags в PhotoDetailView

Теперь давайте добавим тэги tags в PhotoDetailView:

Добавь  Text("Tags: \(photo.tags)") в PhotoDetailView

Я обновляю PhotoDetailView, включив информацию о тегах:

PhotoDetailView.swift 3 / 4
import SwiftUI

struct PhotoDetailView: View {
    let photo: FlickrPhoto
   
    var body: some View {
       ScrollView {
        VStack(alignment: .leading, spacing: 16) {
         AsyncImage(url: URL(string: photo.media.originalURL)) { image in
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                } placeholder: {
                    ProgressView()
                }
                .frame(maxWidth: .infinity)
               
                VStack(alignment: .leading, spacing: 8) {
                    Text(photo.title)
                        .font(.title2)
                        .bold()
                   
                    Text("By \(photo.author)")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                   
                    Text("Taken on \(photo.dateTaken)")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                   
                    if !photo.tags.isEmpty {
                        Text("Tags: \(photo.tags)")
                            .font(.subheadline)
                            .foregroundColor(.secondary)
                    }
                }
                .padding()
            }
        }
        .navigationBarTitleDisplayMode(.inline)
    }
}

Ключевые изменения:

  • Добавлено условное Text для отображения тегов photo.tags

  • Использован тот же стиль, что и для другой вторичной информации (шрифт .subheadline и  цвет.secondary)

  • Показывает теги, только если они есть, то есть массив photo.tags не пустой

  • Сохранены одинаковые интервалы и отступы с другими UI элементами.

Кроме того, в PhotoDetailView, для image внутри AsyncImage вручную заменим  .aspectRatio(contentMode: .fit) на .aspectRatio(contentMode: .fill), ведь Cursor AI - это редактор, так что правим код прямо там.

Если все объяснения нас устраивают, опять кликаем Accept All.

В Xcode мы видим, что предварительный просмотр Preview обновлен:

Версия 7

Как часто происходит обращение к  Flickr API при наборе текста в строке поиска?

Наша следующая версия 8 приложения PhotomaniaCursor будет работать в точности как и версия 7, но мы хотим четко увидеть, при каких тегах tags осуществляется обращение к  Flickr API при наборе пользователем текста в строке поиска searchText. Для этого мы будем печатать в FlickrViewModel теги tags, для которых этот запрос выполняется :

if let encodedTags = tags.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
           print ("--------------------------- \(encodedTags)")
          urlString += "&tags=\(encodedTags)           
}

Версия 8

Что нас реально тревожит, так это то, что при вводе «Rose», «Sunrise», «Ocean» в поиск овой строке searchTextкаждого следующего символа (буквы) осуществляется выборка из Flickr.com, что приводит к неприятному мельканию на экране и будет сильно нагружать трафик пользователя на iPhone

Чтобы сэкономить деньги пользователя, мы должны сделать так. чтобы выборка данных с сервера  Flickr.com производилась лишь тогда, когда пользователь закончит вводить данные в поисковой строке. 

Это можно сделать с помощью оператора debounce фреймворка Combine.

Оператор debounce фреймворка Combine

Оператора debounce полезен, когда вы хотите реагировать на ввод пользователя или изменения данных, но не хотите обрабатывать каждое промежуточное значение. К распространенным случаям использования оператора debounce относятся строки поиска, когда вы хотите дождаться, пока пользователь прекратит печатать, прежде чем начать поиск.

Давайте попросим Composer Cursor AI использовать его для нас:

Используй для  FlickrViewModel Combine и .debounce(for:.millisecond(500))

Я обновлю FlickrViewModel для использования Combine с debounce для изменений текста поиска searchText:

FlickrViewModel.swift 2 / 6
import Foundation
import SwiftUI
import Combine

@MainActor
class FlickrViewModel: ObservableObject {
  @Published var photos: [FlickrPhoto] = []
  @Published var searchText = ""
  @Published var isLoading = false
  @Published var errorMessage: String?
 
  private let baseURL = "https://api.flickr.com/services/feeds/photos_public.gne?format=json&nojsoncallback=1"
  private var cancellables = Set<AnyCancellable>()
 
  init() {
      setupSearchSubscription()
  }
 
  private func setupSearchSubscription() {
      $searchText
          .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
          .removeDuplicates()
          .sink { [weak self] _ in
              Task {
                  await self?.fetchPhotos()
              }
          }
          .store(in: &cancellables)
  }
 
  func fetchPhotos() async {
      isLoading = true
      errorMessage = nil
     
      do {
          var urlString = baseURL
       
          if !searchText.isEmpty {
              let tags = searchText
                  .components(separatedBy: " ")
                  .filter { !$0.isEmpty }
                  .joined(separator: ",")
                  
              if let encodedTags = tags.addingPercentEncoding( 
                         withAllowedCharacters: .urlQueryAllowed) {
                  urlString += "&tags=\(encodedTags)"
              }
           }
         
          guard let url = URL(string: urlString) else {
              throw URLError(.badURL)
          }
         
         let (data, _) = try await URLSession.shared.data(from: url)
         let result = try 
             JSONDecoder().decode(FlickrFeedResult.self, from: data)
          photos = result.items
          isLoading = false
      } catch {
          errorMessage = error.localizedDescription
          isLoading = false
      }
  }
}

И обновлю ContentView, чтобы удалить ручной обработчик .onChange, поскольку теперь мы обрабатываем его в ViewModel:

ContentView.swift 6 / 8
struct ContentView: View {
  @StateObject private var viewModel = FlickrViewModel()
 
  let columns = [
      GridItem(.adaptive(minimum: 108), spacing: 1),
      GridItem(.adaptive(minimum: 108), spacing: 1),
      GridItem(.adaptive(minimum: 108), spacing: 1)
  ]
 
  var body: some View {
      NavigationStack {
          ZStack {
              Color.black.opacity(0.1)
             
              ScrollView {
                  LazyVGrid(columns: columns, spacing: 1) {
                      ForEach(viewModel.photos) { photo in
                          NavigationLink(destination: 
                                 PhotoDetailView(photo: photo)) {
                              PhotoGridItem(photo: photo)
                          }
                      }
                  }
              }
             
              if viewModel.isLoading {
                  ProgressView()
                  .scaleEffect(1.5)
                  .frame(maxWidth: .infinity, maxHeight: .infinity)
                      .background(Color.black.opacity(0.1))
              }
          }
          .navigationTitle("Flickr Photos")
          .searchable(text: $viewModel.searchText, 
                      prompt: "Search photos")
          .alert("Error", isPresented: 
                         .constant(viewModel.errorMessage != nil)) {
              Button("OK") {
                  viewModel.errorMessage = nil
              }
          } message: {
              Text(viewModel.errorMessage ?? "")
          }
      }
      .task {
          await viewModel.fetchPhotos()
      }
  }
}

Ключевые изменения:

  • Добавлен import Combine и соответствующая функциональность в FlickrViewModel

  • Добавлена переменная var cancellables = Set<AnyCancellable>()
    для хранения подписок

  • Создан метод setupSearchSubscription() с debounce

  • Удален обработчик .onChange из ContentView

  • Добавлено правильное управление памятью с  [weak self]

  • Добавлен removeDuplicates() для предотвращения ненужных вызовов API

Теперь поиск будет запускаться только через 500 мс после того, как пользователь закончит печатать.Эта реализация обеспечит более плавный поиск за счет сокращения вызовов API и предотвращения немедленных запросов к серверу Flickr.com во время набора текста строке поиска searchText .

Если все объяснения нас устраивают, опять кликаем Accept All.

В Xcode мы видим, что предварительный просмотр Preview обновлен:

Версия 9

Мы видим, что практически нет попыток немедленных запросов к серверу Flickr.com, но малейшая  неуверенность в наборе текста в строке поиска приводит к ненужным запросом, хотя и в меньшей степени, чем прежде.

Кэширование Flickr Photos

Давайте продолжим сокращать число ненужных запросов к серверу Flickr.com и организуем кэш для фотографий загруженных по определенному запросу и опять попросим Composer Cursor AI сделать это для нас:

Добавьте кэш для  FlickrPhotos 

Чтобы добавить кэширование для фотографий Flickr, нам нужно будет изменить FlickrViewModel, включив механизм кэширования. Мы будем использовать NSCache для кэширования в памяти. Вот как мы можем обновить ContentView и FlickrViewMode.

Сначала давайте обновим FlickrViewModel:

FlickrViewModel.swift 3 / 6
import Foundation
import SwiftUI
import Combine

@MainActor
class FlickrViewModel: ObservableObject {
    @Published var photos: [FlickrPhoto] = []
    @Published var searchText = ""
    @Published var isLoading = false
    @Published var errorMessage: String?
   
    private let baseURL = "https://api.flickr.com/services/feeds/photos_public.gne?format=json&nojsoncallback=1"
    private var cancellables = Set<AnyCancellable>()
    private let cache = NSCache<NSString, NSArray>()
   
    init() {
        setupSearchSubscription()
    }
   
    private func setupSearchSubscription() {
        $searchText
            .debounce(for: .milliseconds(500), 
                      scheduler: DispatchQueue.main)
            .removeDuplicates()
            .sink { [weak self] _ in
                Task {
                    await self?.fetchPhotos()
                }
            }
            .store(in: &cancellables)
    }
   
    func fetchPhotos() async {
        isLoading = true
        errorMessage = nil
       
        do {
            let cacheKey = NSString(string: searchText)
            if let cachedPhotos = cache.object(forKey: cacheKey) as? [FlickrPhoto] {
                photos = cachedPhotos
                isLoading = false
                return
            }
           
            var urlString = baseURL
           
            if !searchText.isEmpty {
                let tags = searchText
                    .components(separatedBy: " ")
                    .filter { !$0.isEmpty }
                    .joined(separator: ",")
                    
                if let encodedTags = tags.addingPercentEncoding( 
                         withAllowedCharacters: .urlQueryAllowed) {
                  urlString += "&tags=\(encodedTags)"
                } 
            }
           
            guard let url = URL(string: urlString) else {
                throw URLError(.badURL)
            }
           
         let (data, _) = try await URLSession.shared.data(from: url)
         let result = try JSONDecoder().decode(FlickrFeedResult.self, from: data)
          photos = result.items
          cache.setObject(result.items as NSArray, forKey: cacheKey)
            isLoading = false
        } catch {
            errorMessage = error.localizedDescription
            isLoading = false
        }
    }
} 

Теперь давайте обновим ContentView, чтобы добавить кнопку обновления:

ContentView.swift 7 / 8
import SwiftUI
struct ContentView: View {
    @StateObject private var viewModel = FlickrViewModel()
   
    let columns = [
        GridItem(.adaptive(minimum: 108), spacing: 1),
        GridItem(.adaptive(minimum: 108), spacing: 1),
        GridItem(.adaptive(minimum: 108), spacing: 1)
    ]
   
    var body: some View {
        NavigationStack {
            ZStack {
                Color.black.opacity(0.1)
               
                ScrollView {
                    LazyVGrid(columns: columns, spacing: 1) {
                        ForEach(viewModel.photos) { photo in
                            NavigationLink(destination:  
                                PhotoDetailView(photo: photo)) {
                                PhotoGridItem(photo: photo)
                            }
                        }
                    }
                }
               
                if viewModel.isLoading {
                    ProgressView()
                        .scaleEffect(1.5)
                        .frame(maxWidth: .infinity, 
                               maxHeight: .infinity)
                        .background(Color.black.opacity(0.1))
                }
            }
            .navigationTitle("Flickr Photos")
            .searchable(text: $viewModel.searchText, 
                        prompt: "Search photos")
            .alert("Error", isPresented: 
                        .constant(viewModel.errorMessage != nil)) {
                Button("OK") {
                    viewModel.errorMessage = nil
                }
            } message: {
                Text(viewModel.errorMessage ?? "")
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        Task {
                            await viewModel.fetchPhotos()
                        }
                    }) {
                        Image(systemName: "arrow.clockwise")
                    }
                }
            }
        }
        .task {
            await viewModel.fetchPhotos()
        }
    }
}

Эти изменения реализуют следующее:

В FlickrViewModel:

  • Мы добавили NSCache для хранения извлеченных фотографий.

  • Перед выполнением сетевого запроса мы проверяем, есть ли уже фотографии для текущего поискового запроса в кэше NSCache.

  • Если они найдены в кэше, мы используем кэшированные фотографии вместо выполнения сетевого запроса.

  • После успешного получения новых фотографий мы сохраняем их в кэше.

В ContentView:

  • Мы добавили кнопку обновления в навигационную панель, которая вызывает viewModel.fetchPhotos() при нажатии.

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

Помните, что этот кэш в памяти будет очищен при завершении работы приложения. Для более постоянного кэша вы можете рассмотреть возможность использования Core Data или сохранения данных на диске.

Если все объяснения нас устраивают, опять кликаем Accept All.

В Xcode мы видим, что предварительный просмотр Preview обновлен:

Версия 10

После ввода нескольких поисковых строк searchText формируется кэш с ключами, равными поисковой строке, в том числе и пустой и кнопка обновления ничего не дает, так каквсе «законсервировалось» наглухо, за исключением ситуации, когда обновляется   поисковая строка searchText и её нет в кэше. Кнопка обновления ничего не дает, так как она доступна только, когда в поисковая строка  searchText пуста, а для пустой строки работает кэш.После ввода нескольких поисковых строк searchText формируется кэш с ключами, равными поисковой строке, в том числе и пустой и кнопка обновления ничего не дает, так как все «законсервировалось» наглухо, за исключением ситуации, когда обновляется   поисковая строка searchText и её нет в кэше. Кнопка обновления ничего не дает, так как она доступна только, когда в поисковая строка  searchText пуста, а для нее работает кэш.

Обнуление кеша

Кнопка обновления должна не только выбирать фотографии с помощью  вызова viewModel.fetchPhotos(), но и обнулять кэш. Опять попросим Composer Cursor AI сделать это для нас:

Кнопка обновления должна не только выбирать фотографии путем вызова viewModel.fetchPhotos(), но обновлять кэш.

Я помогу вам изменить FlickrViewModel, чтобы добавить функцию обнуления  кэша. Вот изменения:

FlickrViewModel.swift 4 / 6
import Foundation
import SwiftUI
import Combine
@MainActor
class FlickrViewModel: ObservableObject {
    @Published var photos: [FlickrPhoto] = []
    @Published var searchText = ""
    @Published var isLoading = false
    @Published var errorMessage: String?
   
    private let baseURL =        "https://api.flickr.com/services/feeds/photos_public.gne?format=json&nojsoncallback=1"
    private var cancellables = Set<AnyCancellable>()
    private let cache = NSCache<NSString, NSArray>()
   
    init() {
        setupSearchSubscription()
    }
   
    private func setupSearchSubscription() {
        $searchText
            .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .sink { [weak self] _ in
                Task {
                    await self?.fetchPhotos()
                }
            }
            .store(in: &cancellables)
    }
   
    func fetchPhotos() async {
        isLoading = true
        errorMessage = nil
       
        do {
            let cacheKey = NSString(string: searchText)
            if let cachedPhotos = cache.object(forKey: cacheKey) as? [FlickrPhoto] {
                photos = cachedPhotos
                isLoading = false
                return
            }
           
            var urlString = baseURL
           
            if !searchText.isEmpty {
                let tags = searchText
                    .components(separatedBy: " ")
                    .filter { !$0.isEmpty }
                    .joined(separator: ",")
                    
                 if let encodedTags = tags.addingPercentEncoding( 
                         withAllowedCharacters: .urlQueryAllowed) {
                  urlString += "&tags=\(encodedTags)"
                } 
            }
           
            guard let url = URL(string: urlString) else {
                throw URLError(.badURL)
            }
           
            let (data, _) = try await URLSession.shared.data(from: url)
            let result = try 
                     JSONDecoder().decode(FlickrFeedResult.self, from: data)
            photos = result.items
            cache.setObject(result.items as NSArray, forKey: cacheKey)
            isLoading = false
        } catch {
            errorMessage = error.localizedDescription
            isLoading = false
        }
    }
   
    func clearCache() {
        cache.removeAllObjects()
    }
   
    func refreshPhotos() async {
        clearCache()
        await fetchPhotos()
    }
} 

Добавлены два новых метода в FlickrViewModel

  • clearCache() для удаления всех кэшированных фотографий

  • refreshPhotos(), который объединяет обнуление кеша и извлечение новых фотографий

Теперь, кнопка обновления при нажатии очистит кэш и извлечет свежие фотографии из API Flickr.

Если все объяснения нас устраивают, опять кликаем Accept All.

В Xcode мы видим, что предварительный просмотр Preview обновлен:

Версия 11

Вначале мы действуем как обычно: выбираем фотографии с тегами «Rose» и «Sunrise» с помощью API Flickr и они записываются в кэш. Если мы вообще очищаем поисковую строку, то перечень фотографий также выбирается из кэша, поскольку пустая строка searchText также записывается в кэш со своими фотографиями:

let cacheKey = NSString(string: searchText)

Но если мы хотим выбрать свежую информацию, то кликаем на кнопке обновления, которая очищает кэш и извлекает свежие фотографии из API Flickr.

Если мы внимательно посмотрим на подробную информацию  о фотографии, то обнаружим, что дата получения фотографии dataUpload имеет странный формат:

Это происходит потому, что в Модели свойства dataUpload и published, которые на самом деле являются датами, представлены как строки String:

Так как структура FlickrPhoto является Codable, то мы могли бы преобразовать эти свойства в даты Date при декодировании с помощью JSONDecoder().
Просим об этом Cursor AI:

В Модели свойства published и dateTaken используй как дату Date и обнови JSONDecoder() с JSONDecoder.DateDecodingStratege.iso8601

Я помогу вам изменить модель FlickrPhoto и обновить стратегию декодера. 
Вот изменения:

FlickrFeedResult.swift 1 / 1
struct FlickrFeedResult: Codable {
    let items: [FlickrPhoto]
}

struct FlickrPhoto: Codable, Identifiable {
    let title: String
    let link: String
    let media: Media
    let dateTaken: Date
    let description: String
    let published: Date
    let author: String
    let tags: String
    
    // Computed property for ID
    var id: String { link }
    
    enum CodingKeys: String, CodingKey {
        case title, link, media
        case dateTaken = "date_taken"
        case description, published, author, tags
    }
}

struct Media: Codable {
    let m: String
    
    var originalURL: String {
        m.replacingOccurrences(of: "_m.", with: ".")
    }
}

И обновите JSONDecoder() в FlickrViewModel:

Вот предложенный код FlickrViewModel:

FlickrViewModel.swift 5 / 6
// ... existing code ...

func fetchPhotos() async {
  isLoading = true
  errorMessage = nil
 
  do {
      let cacheKey = NSString(string: searchText)
      if let cachedPhotos = cache.object(forKey: cacheKey) as? [FlickrPhoto] {
          photos = cachedPhotos
          isLoading = false
          return
      }
     
      var urlString = baseURL
     
      if !searchText.isEmpty {
          let tags = searchText
              .components(separatedBy: " ")
              .filter { !$0.isEmpty }
              .joined(separator: ",")
           if let encodedTags = tags.addingPercentEncoding( 
                         withAllowedCharacters: .urlQueryAllowed) {
                  urlString += "&tags=\(encodedTags)"
                }    
      }
     
      guard let url = URL(string: urlString) else {
          throw URLError(.badURL)
      }
     
      let (data, _) = try await URLSession.shared.data(from: url)
      let decoder = JSONDecoder()
      decoder.dateDecodingStrategy = .iso8601
      let result = try decoder.decode(FlickrFeedResult.self, from: data)
      photos = result.items
      cache.setObject(result.items as NSArray, forKey: cacheKey)
      isLoading = false
  } catch {
      errorMessage = error.localizedDescription
      isLoading = false
  }
}

// ... rest of the code ...

Кроме того, нам необходимо обновить PhotoDetailView, чтобы напрямую использовать новые  Date свойства:

PhotoDetailView.swift 4 / 4
// ... existing code ...

Text("Taken on \(formatDate(photo.dateTaken))")
    .font(.subheadline)
    .foregroundColor(.secondary)

// ... rest of the code ...

// Update formatDate to take Date instead of optional Date
private func formatDate(_ date: Date) -> String {
    let formatter = DateFormatter()
    formatter.dateStyle = .long
    formatter.timeStyle = .short
    return formatter.string(from: date)
}

Ключевые изменения:

  1. Сделайте свойства dateTaken и published типами Date вместо String

  2. Настройте JSONDecoder для автоматической обработки дат ISO8601

  3. Удалите вычисляемое свойство dateUpload, поскольку теперь у нас есть свойство published как правильная дата Date

  4. Обновите функцию formatDate, чтобы она принимала НЕ Optional Date

Это обеспечит надлежащую обработку дат Date во всем приложении и более понятный код форматирования дат. Теперь дата получения фотографии выглядит так:

 Замена протокола ObservableObject на макрос @Observable

Используй макрос @Observable для FlickrViewModel и используй Combine и var searchSubject = PassthroughSubject<String, Never>()

Я помогу вам обновить FlickrViewModel для использования нового макроса @Observable и Combine PassthroughSubject. Вот измененная версия:

FlickrViewModel .swift 6 / 6
import Foundation
import SwiftUI
import Combine

@Observable
final class FlickrViewModel {
    var photos: [FlickrPhoto] = []
    var searchText = ""
    var isLoading = false
    var errorMessage: String?
   
    private let baseURL = "https://api.flickr.com/services/feeds/photos_public.gne?format=json&nojsoncallback=1"
    private var cancellables = Set<AnyCancellable>()
    private let cache = NSCache<NSString, NSArray>()
    private let searchSubject = PassthroughSubject<String, Never>()
   
    init() {
        setupSearchSubscription()
    }
   
    private func setupSearchSubscription() {
        searchSubject
            .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .sink { [weak self] _ in
                Task { @MainActor in
                    await self?.fetchPhotos()
                }
            }
            .store(in: &cancellables)
    }
   
    func updateSearchText(_ newText: String) {
        searchText = newText
        searchSubject.send(newText)
    }
   
    @MainActor
    func fetchPhotos() async {
        isLoading = true
        errorMessage = nil
       
        do {
            let cacheKey = NSString(string: searchText)
            if let cachedPhotos = 
                 cache.object(forKey: cacheKey) as? [FlickrPhoto] {
                photos = cachedPhotos
                isLoading = false
                return
            }
           
            var urlString = baseURL
           
            if !searchText.isEmpty {
                let tags = searchText
                    .components(separatedBy: " ")
                    .filter { !$0.isEmpty }
                    .joined(separator: ",")
                    
                if let encodedTags = tags.addingPercentEncoding( 
                         withAllowedCharacters: .urlQueryAllowed) {
                  urlString += "&tags=\(encodedTags)"
                }    
            }
           
            guard let url = URL(string: urlString) else {
                throw URLError(.badURL)
            }
           
            let (data, _) = 
                try await URLSession.shared.data(from: url)
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            let result = 
              try decoder.decode(FlickrFeedResult.self, from: data)
            photos = result.items
            cache.setObject(result.items as NSArray, 
                            forKey: cacheKey)
            isLoading = false
        } catch {
            errorMessage = error.localizedDescription
            isLoading = false
        }
    }
   
    func clearCache() {
        cache.removeAllObjects()
    }
   
    @MainActor
    func refreshPhotos() async {
        clearCache()
        await fetchPhotos()
    }
}

А вот обновленный ContentView для работы с новой FlickrViewModel:

ContentView.swift 8 / 8
struct ContentView: View {
    @State private var viewModel = FlickrViewModel()
   
    let columns = [
        GridItem(.adaptive(minimum: 108), spacing: 1),
        GridItem(.adaptive(minimum: 108), spacing: 1),
        GridItem(.adaptive(minimum: 108), spacing: 1)
    ]
   
    var body: some View {
        NavigationStack {
            ZStack {
                Color.black.opacity(0.1)
               
                ScrollView {
                    LazyVGrid(columns: columns, spacing: 1) {
                        ForEach(viewModel.photos) { photo in
                            NavigationLink(destination: 
                                 PhotoDetailView(photo: photo)) {
                                PhotoGridItem(photo: photo)
                            }
                        }
                    }
                }
               
                if viewModel.isLoading {
                    ProgressView()
                        .scaleEffect(1.5)
                        .frame(maxWidth: .infinity, 
                               maxHeight: .infinity)
                        .background(Color.black.opacity(0.1))
                }
            }
            .navigationTitle("Flickr Photos")
            .searchable(
                text: .init(
                    get: { viewModel.searchText },
                    set: { viewModel.updateSearchText($0) }
                ),
                prompt: "Search photos"
            )
            .alert("Error", isPresented:
                      .constant(viewModel.errorMessage != nil)) {
                Button("OK") {
                    viewModel.errorMessage = nil
                }
            } message: {
                Text(viewModel.errorMessage ?? "")
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        Task {
                            await viewModel.refreshPhotos()
                        }
                    }) {
                        Image(systemName: "arrow.clockwise")
                    }
                }
            }
        }
        .task {
            await viewModel.fetchPhotos()
        }
    }
}

Ключевые изменения:

  1. Заменены @MainActor class и @Published на новый макрос @Observable

  2. Добавлен PassthroughSubject для обработки текста поиска

  3. Создан новый метод updateSearchText для обработки изменений текста поиска

  4. Cделали ​​FlickrViewModel final class

  5. Изменен @StateObject на @State в ContentView, поскольку мы используем @Observable

  6. Обновлён ​​ .searchable для использования нового метода updateSearchText

  7. Добавлены явные аннотации @MainActor, где это необходимо

  8. Удалено соответствие протоколу ObservableObject, поскольку оно больше не нужно

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

Очень крутой код, сочетающий макрос @Observable с Combine

Посмотрим, как это работает. Для контроля того, что нет ненужных «паразитный» запросов к Flickr API  при наборе строки поиска searchText, будем как и раньше печатать tags, для которых этот запрос выполняется:

 if let encodedTags = tags.addingPercentEncoding (
                                  withAllowedCharacters: .urlQueryAllowed) {
                    print ("--------------------------- \(encodedTags)")
                    urlString += "&tags=\(encodedTags)"
  }                             

Версия 12

Мы видим, что все работает великолепно — никаких ненужных запросов при наборе в поисковой строке «Rose», «Sunrise», «Ocean» нет. Происходит однократная выборка и запись в кэш. Поэтому при повторном наборе «Sunrise» это слово на консоли не появляется, так как выборка фотографий идет из кэша.

Окончательный вариант iOS приложения PhotomaniaCursor находится на Github.com
Отмечу, что я использовала LLM Claude 3.5 Sonnet

… которая очень хороша для программирования, но будьте осторожны с Claude 3.5 Sonnet, которая почти всегда предоставляет работающее приложение, иногда с ошибками, которые очень трудно найти. Не поддавайтесь эйфории и не давайте ей завести себя в запутанную ситуацию, проверяйте и анализируйте код. К счастью, в этом проекте таких ситуаций не было. 

Как видно, возможно использование и других LLM.

Работа с отдельным фрагментом кода

До сих пор мы использовали всегоодну «фишку» Cursor AI — Composer, a их там много: Chat, ⌘K, ⌘L, Tab, Editor и Terminal.

Например, с помощью ⌘ K можно рефакторить фрагмент кода, a можно запросить объяснения того, что делает этот код, и Cursor AI добавит вам комментарии, разъясняющие этот фрагмент кода — это отличный способ учиться программированию:

ЗАКЛЮЧЕНИЕ.

Используя Composer Cursor AI нам удалось создать с помощью нескольких шагов полноценное iOS приложение выборки и показа публичных фотографий с Flickr.com, не написав практически ни одной строчки кода на SwiftUI. В результате мы получили очень компактный и хорошо читаемый код, Это не значит, что не нужно знать Swift и SwiftUI, наоборот, все наши подсказки (Prompts) были сделаны на основе анализа полученного кода именно с учетом знаний Swift и SwiftUI. 

Но код генерился в Cursor AI и запускался в Xcode так легко и быстро, что понадобилось всего несколько минут, чтобы получить такое приложение. Возможно, это объясняется квалифицированными подсказками (Prompts),  но время на написание iOS приложения экономит невероятно.

Окончательный вариант iOS приложения PhotomaniaCursor находится на Github.com

P.S. 

Основойдля этих постов стало видео «I Made an iOS App in MINUTES with This AI Tool!» («Я сделал это iOS приложение в течении нескольких минут с помощью этого ИИ инструмента»).

Я не верю, что без знания Swift и SwiftUI или UIKit можно создать полноценное iOS приложение, но мне было интересно, какие «фишки» использовал автор внутри Cursor AI, их там много: Chat, Composer, ⌘K, Tab, Editor и Terminal.

Оказалось, что автор этого видео использует только Cursor AI Composer, который работает с целым проектом. Это замечательное видео, вы можете его посмотреть или прочитать его русскоязычный конспект здесь. A также увидеть приложение «Приливы» Tide Times на Github, которое я создала, следуя этому видео. Но доступ к информации о «приливах» требует API key и первые 100 запросов вы делаете бесплатно, a затем нужна платная подписка.

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


  1. ukmsz
    04.12.2024 13:48

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


    1. kns00
      04.12.2024 13:48

      Подтверждаю. Я использую Claude 3.5 Sonnet в Cursor. Ни разу не писал промпты к AI на английском и он всегда отлично генерил код. Даже не думал переходить на английский.


    1. WildGreyPlus Автор
      04.12.2024 13:48

      Возможно. Но у меня отрицательный опыт с ChatGPT 4.o-mini на русском - качество Swift SwiftUI кода намного ниже, хотя в целом код неплохой. Поэтому предпочитаю английский. В чистом Claude 3.5 Sonnet без Cursor - та же проблема. Я объясняю это базой обучения.