Привет, товарищи iOS-разработчики! Я очень рад поделиться с вами этой записью в блоге! Долгое время мне не терпелось рассказать вам все о моей новообретенной любви: contextMenu. По общему признанию, я не использовал contextMenus в прошлом и не реализовал весь его потенциал. Но изучив красивое приложение Apple Music, чтобы воспроизвести его для своего портфолио, я наконец понял магию contextMenus.

Если вы хотите сделать свое приложение более отзывчивым или просто хотите предоставить своим пользователям доступ к определенным функциям с помощью различных методов, таких как длительное нажатие или скрытая кнопка, то вам обязательно нужно прочитать этот пост! Я покажу вам не только, как и когда использовать contextMenus, но и как настроить предварительный просмотр для них в iOS 15. Поверьте, вы не захотите это пропустить!

Контекстное меню обеспечивает доступ к функциям, непосредственно связанным с элементом на экране, не загромождая интерфейс. В этом случае элементом на экране является Song Cell. ContextMenu открывается при длительном нажатии, определяемом системой.

В начале я хотел бы предупредить вас о неправильном использовании contextMenus. В соответствии с рекомендациями Apple по пользовательскому интерфейсу:

Всегда делайте пункты контекстного меню доступными и в основном интерфейсе. Например, в приложении «Почта» в iOS и iPadOS элементы контекстного меню, доступные для сообщения в папке «Входящие», также доступны на панели инструментов представления сообщения. В macOS в меню строки меню приложения перечислены все команды приложения, в том числе в различных контекстных меню.

Предварительный просмотр contextMenu — это предварительный просмотр элемента или представления, которое будет представлено при основном действии элемента. В случае с Apple Music Song Cell показывает: название альбома, год выпуска и тип песни. Все эти данные не отображаются в нормальном состоянии Song Cell. Это означает, что предварительный просмотр также можно использовать для преобразования существующей ячейки.

Теперь давайте запачкаем руки!

Во-первых, давайте создадим новый проект с пустым представлением SwiftUI. Внутри этого представления вставьте код ниже:

@State private var size = CGSize(width: 50, height: 50)
    
    var body: some View {

        RoundedRectangle(cornerRadius: 10)
            .frame(width: 100, height: 100)
            .foregroundColor(.green)
            .overlay {
                Text("Context")
                    .foregroundColor(.white)
                    .font(.title2)
                    .cornerRadius(10)
                    .shadow(radius: 3.5)
                    .frame(width: 100, height: 100)
            }
            .contextMenu {
                //Our context Menu actions selection
            }

    }

Вот что вы должны увидеть в предварительном просмотре контента:

Поскольку в нашем модификаторе contextMenu нет элементов, даже если мы долго нажимаем на наш RoundedRectangle, ничего не произойдет. Теперь давайте настроим наше contextMenu, добавим кнопки и придадим им какое-то назначение.

Добавьте две переменные состояния, которые будут изменяться в действиях contextMenu:

@State private var text: String = "Green"
@State private var color: Color = .green

Измените RoundedRectangle и Text следующим образом:

RoundedRectangle(cornerRadius: 10)
            .frame(width: 100, height: 100)
            .foregroundColor(color)
            .overlay {
                Text(text)
                    .foregroundColor(.white)
                    .font(.title2)
                    .cornerRadius(10)
                    .shadow(radius: 3.5)
                    .frame(width: 100, height: 100)
            }

Наконец, добавьте приведенный ниже код в свой модификатор contextMenu:

.contextMenu {
    //Our context Menu actions selection
    Button(action:{
         self.text = "Red"
         self.color = .red
     }){
         Text("Set Red")
       }
     Button(action:{
         self.text = "Green"
         self.color = .green
     }){
         Text("Set Green")
     }
    //
}

Теперь, когда мы долго нажимаем на наш прямоугольник, мы, наконец, видим наше контекстное меню и при нажатии на наши кнопки текст и цвет прямоугольника меняются!

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

В нашем предварительном просмотре мы будем изменять размер RoundedRectangle вместе с отображением HEX-кода для текущего представленного цвета. Для этого измените код следующим образом:

@State private var currentColorHex: String = "33C758"

func getHexCode(color: Color) -> String {
        
        let ciColor = CIColor(color: UIColor(color))
        let alpha = ciColor.alpha
        let red = ciColor.red
        let blue = ciColor.blue
        let green = ciColor.green
        
        // Create a hex code string from the RGB components
        let hexCode = String(format: "%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
        
        print(hexCode)
        return hexCode
    }

Здесь мы объявляем новую переменную состояния, которая содержит наш шестнадцатеричный код, а также устанавливаем функцию, которая использует CIColor для получения нашего RGB-кода цветов, который мы затем будем использовать для форматирования его в шестнадцатеричный код.

После изменения действий наших кнопок следующим образом:

//Our context Menu actions selection
    Button(action:{
         self.text = "Red"
         self.color = .red
         currentColorHex = getHexCode(color: color)
     }){
         Text("Set Red")
       }
     Button(action:{
         self.text = "Green"
         self.color = .green
         currentColorHex = getHexCode(color: color)
     }){
         Text("Set Green")
     }
    //

Вот результат, который мы получаем при печати нашего HEX-кода, и да, я проверил значения, и они действительны.

Теперь давайте, наконец, воспользуемся предварительным просмотром нашего contextMenu! в iOS 16 у contextMenu есть специальный предварительный просмотр свойств, который делает настройку пользовательского предварительного просмотра несправедливо простой. Вот что вам нужно сделать, чтобы реализовать предварительный просмотр в iOS 16:

  1. Поскольку мы не используем пользовательскую ячейку для определения ширины и высоты, мы будем использовать функцию @ViewBuilder для установки размера RoundedRectangles:
@ViewBuilder
    func CustomRectangle(size: CGSize, isPreview: Bool) -> some View {
        
        RoundedRectangle(cornerRadius: 10)
            .frame(width: size.width, height: size.height)
            .foregroundColor(color)
            .overlay {
                VStack(alignment: .leading, spacing: 5) {
                    Text(text)
                        .foregroundColor(.white)
                        .font(isPreview ? .title: .title2)
                        .cornerRadius(10)
                        .shadow(radius: 3.5)
                        .frame(width: 100, height: 40)
                    
                    if isPreview {
                        Text(currentColorHex)
                            .foregroundColor(.white.opacity(0.75))
                            .font(.title2)
                            .cornerRadius(10)
                            .shadow(radius: 3.5)
                            .frame(width: 100, height: 40)
                    }
                }
            }
        
    }

2. Измените ContentView следующим образом:

var body: some View {
        
        CustomRectangle(size: CGSize(width: 100, height: 100), isPreview: false)
            .contextMenu {
                //Our context Menu actions selection
                Button(action:{
                    self.text = "RED"
                    self.color = .red
                    currentColorHex = getHexCode(color: color)
                }){
                    Text("Set Red")
                }
                Button(action:{
                    self.text = "GREEN"
                    self.color = .green
                    currentColorHex = getHexCode(color: color)
                }){
                    Text("Set Green")
                }
                //
            } preview: {
                CustomRectangle(size: CGSize(width: 250, height: 150), isPreview: true)
            }

        
    }

Теперь, когда мы создадим наш проект, это то, что мы должны увидеть:

Как видите, contextMenu работает как положено. Давайте теперь посмотрим, что нам нужно сделать, чтобы реализовать ту же функциональность в iOS 15.

Предварительный просмотр ContextMenu в iOS 15

Сначала давайте создадим новый файл Swift с именем ContextMenuWithPreview, который будет содержать все наши расширения, необходимые для предварительного просмотра. Вот шаги, которые мы будем выполнять:

  1. Добавьте скрытое наложение UIContextMenuInteraction.
  2. Обеспечьте предварительный просмотр в previewProviderи действия в actionProvider.
  3. Используйте @ViewBuilder, чтобы упростить объявление предварительного просмотра.
import SwiftUI

// MARK: - Custom Menu Context Implementation
struct PreviewContextMenu<Content: View> {
    let size: CGSize
    var color: Binding<Color>
    var text: Binding<String>
    let destination: Content
    let actionProvider: UIContextMenuActionProvider?
    
    init(size: CGSize, color: Binding<Color>, text: Binding<String>, destination: Content, actionProvider: UIContextMenuActionProvider? = nil) {
        self.size = size
        self.destination = destination
        self.actionProvider = actionProvider
        self.color = color
        self.text = text
    }
}

// UIView wrapper with UIContextMenuInteraction
struct PreviewContextView<Content: View>: UIViewRepresentable {
    
    let menu: PreviewContextMenu<Content>
    let didCommitView: () -> Void
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        view.backgroundColor = .clear
        let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
        view.addInteraction(menuInteraction)
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) { }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(menu: self.menu, didCommitView: self.didCommitView)
    }
    
    class Coordinator: NSObject, UIContextMenuInteractionDelegate {
        
        let menu: PreviewContextMenu<Content>
        let didCommitView: () -> Void
                
        init(menu: PreviewContextMenu<Content>, didCommitView: @escaping () -> Void) {
            self.menu = menu
            self.didCommitView = didCommitView
        }
                
        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
            let previewProvider: () -> UIViewController? = {
                let viewController = UIHostingController(rootView: self.menu.destination)
                viewController.preferredContentSize = self.menu.size
                return viewController
            }
            return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: self.menu.actionProvider)
        }
        
        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
            animator.addCompletion(self.didCommitView)
        }
        
        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
            let parameters = UIPreviewParameters()
            parameters.backgroundColor = .clear
            return UITargetedPreview(view: interaction.view!, parameters: parameters)
        }

        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
            let parameters = UIPreviewParameters()
            parameters.backgroundColor = .clear
            return UITargetedPreview(view: interaction.view!, parameters: parameters)
        }
        
    }
}

// Add context menu modifier
extension View {
    func contextMenu<Content: View>(_ menu: PreviewContextMenu<Content>) -> some View {
        self.modifier(PreviewContextViewModifier(menu: menu))
    }
}

struct PreviewContextViewModifier<V: View>: ViewModifier {

    let menu: PreviewContextMenu<V>
    @Environment(\.presentationMode) var mode
    
    @State var isActive: Bool = false
    
    func body(content: Content) -> some View {
        Group {
            if isActive {
                menu.destination
            } else {
                content.overlay(PreviewContextView(menu: menu, didCommitView: { self.isActive = true }))
            }
        }
    }
}

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

if #available(iOS 16.0, *) {
            CustomRectangle(size: CGSize(width: 100, height: 100), isPreview: false)
                .contextMenu {
                    //Our context Menu actions selection
                    Button(action:{
                        self.text = "RED"
                        self.color = .red
                        currentColorHex = getHexCode(color: color)
                    }){
                        Text("Set Red")
                    }
                    Button(action:{
                        self.text = "GREEN"
                        self.color = .green
                        currentColorHex = getHexCode(color: color)
                    }){
                        Text("Set Green")
                    }
                    //
                } preview: {
                    CustomRectangle(size: CGSize(width: 250, height: 150), isPreview: true)
                }
                .previewLayout(<#T##value: PreviewLayout##PreviewLayout#>)
        } else {
            // Fallback on earlier versions
            
        }

Вот что вы должны добавить в замыкание с помощью // Fallback в более ранних версиях:

// Fallback on earlier versions
CustomRectangle(size: CGSize(width: 100, height: 100), isPreview: false)
                .contextMenu(PreviewContextMenu(size: CGSize(width: 250, height: 150), color: $color, text: $text, destination: CustomRectangle(size: CGSize(width: 250, height: 150), isPreview: true), actionProvider: { items in
                    return UIMenu(title: "", children: [greenAction, redAction])
                }))

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

Объявите их в теле нашего contentView:

let greenAction = UIAction(
            
                title: "Set Green",
                identifier: nil,
                handler: { _ in
                    text = "GREEN"
                    color = .green
                    currentColorHex = getHexCode(color: color)
                }
                
            )
        
        let redAction = UIAction(
            
                title: "Set Red",
                identifier: nil,
                handler: { _ in
                    text = "RED"
                    color = .red
                    currentColorHex = getHexCode(color: color)
                }
                
            )

Теперь давайте запустим наш проект!

Хотя кажется, что все работает нормально, мы ясно видим, что предварительный просмотр не обновляется при изменении цветов. Причина этого в том, что CustomRectange в нашем предварительном просмотре установлен и не обновляется после этого. В то время как наш пункт назначения взят после завершения действия по изменению цвета. Чтобы решить проблему, нам нужно будет сделать следующее:

  1. Объявите наш пользовательский прямоугольник в пользовательском классе
  2. Установите значения Binding для нашего color, text и hexCode.

Создайте новый класс с именем CustomRectangle и вставьте следующий код:

struct CustomRectangle: View {
    
    @State var size: CGSize
    @State var isPreview: Bool
    
    @Binding var color: Color
    @Binding var text: String
    @Binding var currentColorHex: String
    
    var body: some View {
        RoundedRectangle(cornerRadius: 10)
            .frame(width: size.width, height: size.height)
            .foregroundColor(color)
            .overlay {
                VStack(alignment: .leading, spacing: 5) {
                    Text(text)
                        .foregroundColor(.white)
                        .font(isPreview ? .title: .title2)
                        .cornerRadius(10)
                        .shadow(radius: 3.5)
                        .frame(width: 100, height: 40)
                    
                    if isPreview {
                        Text(currentColorHex)
                            .foregroundColor(.white.opacity(0.75))
                            .font(.title2)
                            .cornerRadius(10)
                            .shadow(radius: 3.5)
                            .frame(width: 100, height: 40)
                    }
                }
            }
    }
}

Затем измените наш ContentView, установив отсутствующие значения нашего нового CustomRectangle:

struct ContentView: View {
        
    @State private var text: String = "GREEN"
    @State private var color: Color = .green
    @State private var currentColorHex: String = "33C758"
    
    var body: some View {
        
        let greenAction = UIAction(
            
                title: "Set Green",
                identifier: nil,
                handler: { _ in
                    text = "GREEN"
                    color = .green
                    currentColorHex = getHexCode(color: color)
                }
                
            )
        
        let redAction = UIAction(
            
                title: "Set Red",
                identifier: nil,
                handler: { _ in
                    text = "RED"
                    color = .red
                    currentColorHex = getHexCode(color: color)
                }
                
            )
        
        if #available(iOS 16.0, *) {
            CustomRectangle(size: CGSize(width: 100, height: 100), isPreview: false, color: $color, text: $text, currentColorHex: $currentColorHex)
                .contextMenu {
                    //Our context Menu actions selection
                    Button(action:{
                        self.text = "RED"
                        self.color = .red
                        currentColorHex = getHexCode(color: color)
                    }){
                        Text("Set Red")
                    }
                    Button(action:{
                        self.text = "GREEN"
                        self.color = .green
                        currentColorHex = getHexCode(color: color)
                    }){
                        Text("Set Green")
                    }
                    //
                } preview: {
                    CustomRectangle(size: CGSize(width: 250, height: 150), isPreview: true, color: $color, text: $text, currentColorHex: $currentColorHex)
                }
        } else {
            // Fallback on earlier versions
            CustomRectangle(size: CGSize(width: 100, height: 100), isPreview: false, color: $color, text: $text, currentColorHex: $currentColorHex)
                .contextMenu(PreviewContextMenu(size: CGSize(width: 250, height: 150), color: $color, text: $text, destination: CustomRectangle(size: CGSize(width: 250, height: 150), isPreview: true, color: $color, text: $text, currentColorHex: $currentColorHex), actionProvider: { items in
                    return UIMenu(title: "", children: [greenAction, redAction])
                }))

        }

        
    }
    
    func getHexCode(color: Color) -> String {
        
        let ciColor = CIColor(color: UIColor(color))
        let red = ciColor.red
        let blue = ciColor.blue
        let green = ciColor.green
        
        // Create a hex code string from the RGB components
        let hexCode = String(format: "%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
        
        print(hexCode)
        return hexCode
    }
    
}

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

Поздравляем! Теперь вы знаете, как реализовать Custom ContextView с Preview и Destination в iOS 15.

Спасибо, что прочитали эту статью!

Вы можете найти исходный код этого проекта и многих других интересных проектов на моем github: https://github.com/AisultanAskarov

Следите за моим твиттером, где я также публикую небольшие, но очень полезные фрагменты кода: https://twitter.com/aisultanios