В этом руководстве мы научимся внедрять в приложения SwiftUI холст Freeform, обмен сообщениями в чате, голосовые вызовы и видеозвонки. Воспользуемся фреймворком PencilKit от Apple с легким в освоении холстом для рисования и разнообразным инструментарием для создания рукописных заметок и набросков.

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

Рисуйте карандашом Apple Pencil или пальцами в приложении iOS/SwiftUI, которое вы создадите в этом руководстве самостоятельно.

Потребуется установить:

  • среду разработки Xcode 15;
  • чат Stream со SwiftUI для обмена сообщениями, прикрепления к ним заметок и рисунков;
  • видео Stream со SwiftUI для аудио- и видеозвонков;
  • фреймворк PencilKit для фиксации данных, вводимых пальцем и с помощью Apple Pencil, в виде рисунков.

Весь функционал приложения, кроме видеозвонков, тестируется симулятором iOS. Однако на iPad рекомендуется тестировать с помощью Apple Pencil, а на iPhone ― пальцем.

Пример финального проекта

Приложение для рисования со SwiftUI и PencilKit

На видео выше представлен финальный проект, создаваемый в этом руководстве. Он выложен на GitHub. Инструменты совместной работы расположены на холсте справа вверху, значки справа внизу ― это инструменты рисования, а слева внизу ― для корректирования рисунков. В приложении также имеются стандартные инструменты рисования PencilKit слева вверху, а также линейка для рисования прямых и диагональных линий.

Настройка проекта

Создадим в Xcode SwiftUI-проект FaceBoard и подготовим его для написания кода демо-приложения.

Добавление группы папок и файлов Swift

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

Установка SDK-пакетов чата и видео Stream

Разберем зависимости пакетов, показанные на изображении выше:

  • StreamChat для функционала чата в приложении;
  • StreamChatSwiftUI ― настраиваемый, переиспользуемый компонент SwiftUI для создания возможностей общения в чате;
  • StreamVideo ― основной SDK-пакет для видеозвонков, который состоит из компонентов SwiftUI;
  • StreamWebRTC для функционала видеозвонков в приложении;
  • SwiftProtobuf ― альтернатива JSON и XML для сериализации структурированных данных.

Чтобы добавить все пять зависимостей пакетов, установим SDK-пакеты чата и видео Stream для iOS: в проекте Xcode переходим в File («Файл») -> Add Package Dependencies («Добавить зависимости пакетов»), копируем и вставляем URL-адреса и следуем инструкциям по установке.

Задание разрешений для защищенных файлов пользователей

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

Для голосовых и видеозвонков потребуются микрофон и камера, а для защищенных пользовательских файлов ― настройки конфиденциальности в Xcode. Во вкладке Info главной папки проекта добавляем privacy для фото, камеры и микрофона:

Обзор PencilKit

PencilKit ― это фреймворк Apple, которым в приложениях macOS, iOS и visionOS функционал рисования реализуется с малой задержкой путем фиксации и отображения данных, вводимых пальцами, и с помощью Apple Pencil. С PencilKit разработчики быстро встраивают в приложения рисованный контент, заметки, разметку документов или изображений.

Стандартный инструментарий рисования позволяет создавать, стирать, отменять, выбирать. Чтобы обеспечить интегрированным приложениям идеальную ― до пикселя ― точность рисования, в среде создания эскизов поддерживаются чувствительность к наклону и защита от случайных прикосновений.

Интегрируя PencilKit в приложение iOS, мы реализуем такой функционал:

  • Точное рисование пальцем и карандашом. Пользователи рисуют, делают заметки и точные иллюстрации.
  • Сверхмалая задержка. Рисуете вы на экране устройства iOS пальцем или с помощью Apple Pencil, ощущения такие же, как если бы делали это обычным карандашом на бумаге.
  • Чувствительность к нажатию в иллюстрациях. Беспроблемная реакция в инструментах вроде Fountain Pen как на легкие, так и на глубокие нажатия/надавливания при рисовании тонких и толстых линий и кривых.
  • Чувствительность к наклону при затенении. При использовании Apple Pencil на холсте автоматически поддерживается функционал наклона при затенении.

Функционал совместной работы: рисование, обмен сообщениями, звонки

Рисование с PencilKit, обмен сообщениями в чате и видеозвонки со Stream

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

Для отправки идей сохраните то, что нарисовали на холсте, нажав кнопку Save («Сохранить») справа вверху. Этим действием рисунки на холсте сохраняются в фотогалерее iOS. Чтобы открыть список участников ― каналов чата, ― нажмите значок чата. Прокрутив список, выберите участника, а затем сохраненный в фотогалерее рисунок и прикрепите его к отправляемому сообщению.

На видео выше демонстрируется, как изображение рисуется, сохраняется и прикрепляется к сообщению.

Пройдя все этапы этого руководства или запустив приложение после его загрузки с GitHub, нажимаем кнопку Video («Видео») 🎥 справа вверху и инициируем вызов, после чего приглашаем участников присоединиться, нажимая там же person.2:

Добавляем обмен сообщениями для совместной работы

Настроим установленные ранее SDK-пакеты чата и видео Stream, вот структура папок и файлы для чата:

Создадим папку ChatMessaging и добавим содержимое следующих файлов Swift:

Настройку SDK-пакета чата разбирать не будем, пошаговое объяснение имеется в документации iOS здесь.

Добавляем видеозвонки

Настройки видеозвонков тоже находятся в папке, добавляем следующие файлы Swift и их содержимое в ссылки ниже:

Как настроить SDK-пакет видео, смотрите здесь и в руководстве по видеозвонкам.

Создаем холст для рисования

Добавим в SwiftUI-проект доску для рисования, для простоты реализуем ее в одном файле Swift. Переименовываем ContentView.swift, полученный при создании проекта, в FreeFormDrawingView.swift, а его содержимое заменяем таким кодом:

//
// FreeFormDrawingView.swift
// FaceBoard

import SwiftUI
import PencilKit
import StreamVideo
import StreamVideoSwiftUI
import StreamChat
import StreamChatSwiftUI

struct FreeFormDrawingView: View {

@ObservedObject var viewModel: CallViewModel
// Чтобы фиксировать касания карандаша Apple Pencil и пальцев пользователя, определяем переменную состояния.
@State private var canvas = PKCanvasView()
@State private var isDrawing = true
@State private var color: Color = .black
@State private var pencilType: PKInkingTool.InkType = .pencil
@State private var colorPicker = false

@State private var isMessaging = false
@State private var isVideoCalling = false
@State private var isScreenSharing = false
@State private var isRecording = false
@Environment(\.dismiss) private var dismiss
@Environment(\.undoManager) private var undoManager

var body: some View {
NavigationStack {
// Представление рисунка
DrawingView(canvas: $canvas, isDrawing: $isDrawing, pencilType: $pencilType, color: $color)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button {
// Очищаем холст. Сбрасываем рисунок
canvas.drawing = PKDrawing()
} label: {
Image(systemName: "scissors")
}

Button {
// Отменяем рисунок
undoManager?.undo()
} label: {
Image(systemName: "arrow.uturn.backward")
}

Button {
// Восстанавливаем рисунок
undoManager?.redo()
} label: {
Image(systemName: "arrow.uturn.forward")
}

Button {
// Инструмент «Ластик»
isDrawing = false
} label: {
Image(systemName: "eraser.line.dashed")
}

Divider()
.rotationEffect(.degrees(90))

Button {
// Средство выбора инструментов
//let toolPicker = PKToolPicker.init()
@State var toolPicker = PKToolPicker()
toolPicker.setVisible(true, forFirstResponder: canvas)
toolPicker.addObserver(canvas)
canvas.becomeFirstResponder()
} label: {
Image(systemName: "pencil.tip.crop.circle.badge.plus")
}

// Меню для выбора типов и цвета карандашей
Menu {
Button {
// Меню: выбираем цвет
colorPicker.toggle()
} label: {
Label("Color", systemImage: "paintpalette")
}

Button {
// Меню: карандаш
isDrawing = true
pencilType = .pencil
} label: {
Label("Pencil", systemImage: "pencil")
}

Button {
// Меню: ручка
isDrawing = true
pencilType = .pen
} label: {
Label("Pen", systemImage: "pencil.tip")
}

Button {
// Меню: маркер
isDrawing = true
pencilType = .marker
} label: {
Label("Marker", systemImage: "paintbrush.pointed")
}

Button {
// Меню: монолиния
isDrawing = true
pencilType = .monoline
} label: {
Label("Monoline", systemImage: "pencil.line")
}

Button {
// Меню: ручка
isDrawing = true
pencilType = .fountainPen
} label: {
Label("Fountain", systemImage: "paintbrush.pointed.fill")
}

Button {
// Меню: акварель
isDrawing = true
pencilType = .watercolor
} label: {
Label("Watercolor", systemImage: "eyedropper.halffull")
}

Button {
// Меню: цветной карандаш
isDrawing = true
pencilType = .crayon
} label: {
Label("Crayon", systemImage: "pencil.tip")
}

} label: {
Image(systemName: "hand.draw")
}
.sheet(isPresented: $colorPicker) {
ColorPicker("Pick color", selection: $color)
.padding()
}

Spacer()

// Инструменты для рисования
Button {
// Карандаш
isDrawing = true
pencilType = .pencil
} label: {
Label("Pencil", systemImage: "pencil.and.scribble")
}

Button {
// Ручка
isDrawing = true
pencilType = .pen
} label: {
Label("Pen", systemImage: "applepencil.tip")
}

Button {
// Монолиния
isDrawing = true
pencilType = .monoline
} label: {
Label("Monoline", systemImage: "pencil.line")
}

Button {
// Авторучка: вариативные каракули
isDrawing = true
pencilType = .fountainPen
} label: {
Label("Fountain", systemImage: "scribble.variable")
}

Button {
// Маркер
isDrawing = true
pencilType = .marker
} label: {
Label("Marker", systemImage: "paintbrush.pointed")
}

Button {
// Цветной карандаш
isDrawing = true
pencilType = .crayon
} label: {
Label("Crayon", systemImage: "paintbrush")
}

Button {
// Акварель
isDrawing = true
pencilType = .watercolor
} label: {
Label("Watercolor", systemImage: "eyedropper.halffull")
}

Divider()
.rotationEffect(.degrees(90))

// Цветовая палитра
Button {
// Выбираем цвет
colorPicker.toggle()
} label: {
Label("Color", systemImage: "paintpalette")
}

Button {
// Делаем линейку активной
canvas.isRulerActive.toggle()
} label: {
Image(systemName: "pencil.and.ruler.fill")
}
}

// Инструменты совместной работы
ToolbarItemGroup(placement: .topBarTrailing) {
// Обмен сообщениями в чате
Button {
isMessaging.toggle()
} label: {
VStack {
Image(systemName: "message")
Text("Chat")
.font(.caption2)
}
}
.sheet(isPresented: $isMessaging, content: ChatSetup.init)

// Видеозвонки
Button {
isVideoCalling.toggle()
} label: {
VStack {
Image(systemName: "video")
Text("Call")
.font(.caption2)
}
}
.sheet(isPresented: $isVideoCalling, content: CallContainerSetup.init)

// Демонстрация экрана
Button {
isScreenSharing ? viewModel.stopScreensharing() : viewModel.startScreensharing(type: .inApp)
isScreenSharing.toggle()
} label: {
VStack {
Image(systemName: isScreenSharing ? "shared.with.you.slash" : "shared.with.you")
.foregroundStyle(isScreenSharing ? .red : .blue)
.contentTransition(.symbolEffect(.replace))
.contentTransition(.interpolate)
withAnimation {
Text(isScreenSharing ? "Stop" : "Share")
.font(.caption2)
.foregroundStyle(isScreenSharing ? .red : .blue)
.contentTransition(.interpolate)
}
}
}

// Запись экрана
Button {
isRecording.toggle()
} label: {
//Image(systemName: "rectangle.dashed.badge.record")
VStack {
Image(systemName: isRecording ? "rectangle.inset.filled.badge.record" : "rectangle.dashed.badge.record")
.foregroundStyle(isRecording ? .red : .blue)
.contentTransition(.symbolEffect(.replace))
.contentTransition(.interpolate)
withAnimation {
Text(isRecording ? "Stop" : "Record")
.font(.caption2)
.foregroundStyle(isRecording ? .red : .blue)
.contentTransition(.interpolate)
}
}
}

Divider()
.rotationEffect(.degrees(90))

// Сохраняем плоды креативности
Button {
saveDrawing()

} label: {
VStack {
Image(systemName: "square.and.arrow.down.on.square")
Text("Save")
.font(.caption2)
}
}
}
}
}
}

// Сохраняем рисунки в фотогалерее
func saveDrawing() {
// Получаем с холста изображение рисунка
let drawingImage = canvas.drawing.image(from: canvas.drawing.bounds, scale: 1.0)

// Сохраняем рисунки в фотоальбоме
UIImageWriteToSavedPhotosAlbum(drawingImage, nil, nil, nil)
}
}

struct DrawingView: UIViewRepresentable {
// Берем рисунки для сохранения в фотогалерее
@Binding var canvas: PKCanvasView
@Binding var isDrawing: Bool
// Возможность поменять карандаш
@Binding var pencilType: PKInkingTool.InkType
// Возможность поменять цвет карандаша
@Binding var color: Color

//let ink = PKInkingTool(.pencil, color: .black)
// Меняем тип чернил
var ink: PKInkingTool {
PKInkingTool(pencilType, color: UIColor(color))
}

let eraser = PKEraserTool(.bitmap)

func makeUIView(context: Context) -> PKCanvasView {
// Разрешаем рисование пальцем и карандашом
canvas.drawingPolicy = .anyInput
// Инструмент «Ластик»
canvas.tool = isDrawing ? ink : eraser
canvas.alwaysBounceVertical = true

// Средство выбора инструментов
let toolPicker = PKToolPicker.init()
toolPicker.setVisible(true, forFirstResponder: canvas)
toolPicker.addObserver(canvas) // Уведомляемся при изменении настроек средства выбора инструментов
canvas.becomeFirstResponder()

return canvas
}

func updateUIView(_ uiView: PKCanvasView, context: Context) {
// Обновляем инструмент всякий раз, когда обновляется главное представление
uiView.tool = isDrawing ? ink : eraser
}
}

Резюмируем этот код. В любой проект SwiftUI холст и инструменты рисования добавляются так:

  1. Сначала с помощью import PencilKit делаем их доступными.
  2. В структуре FreeFormDrawingView для фиксации данных @State private var canvas = PKCanvasView(), вводимых пальцем и с помощью Apple Pencil, определяем объект PKCanvasView.
  3. Затем определяем свойства для рисования, цвета, типа карандаша и отмены:
@State private var isDrawing = true
@State private var color: Color = .black
@State private var pencilType: PKInkingTool.InkType = .pencil
@State private var colorPicker = false
@Environment(\.undoManager) private var undoManager

4. В SwiftUI нет нативной поддержки PencilKit. Поэтому мы создаем структуру DrawingView, реализующую UIViewRepresentable, и определяем свойства привязки для обновления входных данных о том, рисует ли пользователь, типе карандаша и цвете. Создаем вычисляемую переменную, благодаря которой всякий раз, когда пользователь меняет карандаш, тип карандаша обновляется. Затем, чтобы рисовать пальцем и с Apple Pencil, создаем функцию makeUIView и, когда холст становится first responder, то есть первым готовым реагировать компонентом, отображаем средство выбора инструментов по умолчанию. Наконец, функцией updateUIView отслеживаются изменения переменных привязки.

struct DrawingView: UIViewRepresentable {
// Берем рисунки для сохранения в фотогалерее
@Binding var canvas: PKCanvasView
@Binding var isDrawing: Bool
// Возможность поменять карандаш
@Binding var pencilType: PKInkingTool.InkType
// Возможность поменять цвет карандаша
@Binding var color: Color

//let ink = PKInkingTool(.pencil, color: .black)
// Меняем тип чернил
var ink: PKInkingTool {
PKInkingTool(pencilType, color: UIColor(color))
}

let eraser = PKEraserTool(.bitmap)

func makeUIView(context: Context) -> PKCanvasView {
// Разрешаем рисование пальцем и карандашом
canvas.drawingPolicy = .anyInput
// Инструмент «Ластик»
canvas.tool = isDrawing ? ink : eraser
canvas.alwaysBounceVertical = true

// Средство выбора инструментов
let toolPicker = PKToolPicker.init()
toolPicker.setVisible(true, forFirstResponder: canvas)
toolPicker.addObserver(canvas) // Уведомляемся, когда меняются настройки средства выбора инструментов
canvas.becomeFirstResponder()

return canvas
} // makeUIView

func updateUIView(_ uiView: PKCanvasView, context: Context) {
// Обновляем инструмент всякий раз, когда обновляется главное представление
uiView.tool = isDrawing ? ink : eraser
} // updateUIView
} // DrawingView

Вернемся к структуре FreeFormDrawingView. В вычисляемом свойстве body мы добавляем NavigationStack и создаем экземпляр представления рисунка DrawingView(canvas: $canvas, isDrawing: $isDrawing, pencilType: $pencilType, color: $color). Затем добавляем .toolbar, в различные части которого еще и инструменты рисования, кнопки чата и видео. В конце функцией saveDrawing сохраняем в фотогалерее устройства iOS все, что нарисовали на холсте пользователи.

func saveDrawing() {
// Получаем с холста изображение рисунка
let drawingImage = canvas.drawing.image(from: canvas.drawing.bounds, scale: 1.0)

// Сохраняем рисунки в фотоальбоме
UIImageWriteToSavedPhotosAlbum(drawingImage, nil, nil, nil)
}

Тестирование приложения

Чтобы отобразить холст FreeFormDrawingView и CallContainerSetup, где содержатся конфигурации видеозвонков, поменяем Scene в основном файле приложения FaceBoardApp.swift:

//
// FaceBoardApp.swift
// FaceBoard
//
import SwiftUI
import StreamVideo
import StreamVideoSwiftUI
@main
struct FaceBoardApp: App {
var body: some Scene {
WindowGroup {
ZStack {
CallContainerSetup()
FreeFormDrawingView(viewModel: CallViewModel())
}
}
}
}

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

Что дальше?

Мы научились создавать виртуальную доску SwiftUI для совместной работы с приложением для рисования. Подробнее о том, как в одиночку или вместе работать над идеями в PencilKit, чате и видео Stream, смотрите в документации iOS.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Amos Gyamfi: Add Freeform Drawing, Chat, & Video Calling To Your SwiftUI App

Предыдущая статьяБезопасность Node.js в продакшене: экспертные рекомендации для разработчиков
Следующая статьяКак X оптимизировал обработку 400 миллиардов событий