Как реализовать спойлер-эффект как в Telegram на Swift?

Спойлеры стали неотъемлемой частью общения в мессенджерах и социальных сетях. Они позволяют скрывать часть информации до тех пор, пока пользователь не захочет ее увидеть. В Telegram спойлер-эффект сопровождается красивой анимацией рассыпающихся точек. В этой статье мы рассмотрим, как реализовать подобный спойлер-эффект в iOS-приложении на Swift, используя CAEmitterLayer и UITextView.

6c3763805da8df9ba9c9b9df36342b13.jpgМухаммадиер Расулов

TeamLead IOS в YuSMP Group, автор материала

Цель статьи

●      Показать, как скрывать определенные части текста в UITextView.

●      Реализовать спойлер-эффект с анимацией, похожей на Telegram.

●      Подробно объяснить каждый шаг и участок кода для полного понимания процесса.

90ec27c5506a50359034db116e766b95.jpg

Содержание

Шаг 1: Создание класса SpoilerView

Начнем с создания класса SpoilerView, который будет отвечать за отображение спойлер-эффекта.

import UIKit

class SpoilerView: UIView {
    
    var emitterLayer: CAEmitterLayer!
    
    // Указываем, что слой представления будет CAEmitterLayer
    override class var layerClass: AnyClass {
        return CAEmitterLayer.self
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        isUserInteractionEnabled = true // Включаем взаимодействие с пользователем
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // Метод для запуска анимации спойлера
    func startAnimation() {
        guard let emitterLayer = self.layer as? CAEmitterLayer else { return }
        self.emitterLayer = emitterLayer
        
        // Настраиваем параметры эмиттера
        emitterLayer.emitterPosition = CGPoint(x: bounds.midX, y: bounds.midY) // Позиция эмиттера в центре SpoilerView
        emitterLayer.emitterShape = .rectangle // Форма эмиттера
        emitterLayer.emitterSize = CGSize(width: bounds.size.width, height: bounds.size.height) // Размер эмиттера
        emitterLayer.emitterMode = .surface // Режим эмиссии частиц с поверхности
        emitterLayer.emitterCells = [createEmitterCell()] // Добавляем ячейку эмиттера
    }
    
    // Метод для остановки анимации спойлера
    func stopAnimation() {
        guard emitterLayer != nil else { return }
        emitterLayer.emitterCells = nil // Удаляем ячейки эмиттера
    }
    
    // Создаем и настраиваем ячейку эмиттера
    private func createEmitterCell() -> CAEmitterCell {
        let cell = CAEmitterCell()
        cell.contents = UIImage(named: "dot")?.cgImage // Изображение частицы
        cell.scale = 0.3 // Размер частицы
        cell.scaleRange = 0.15 // Разброс размера частицы
        cell.emissionRange = .pi * 2.0 // 360 градусов для равномерного распространения
        cell.lifetime = 1.5 // Время жизни частицы
        cell.birthRate = dotCount() * 2.0 // Количество частиц в секунду, умноженное на 2 для увеличения количества
        cell.velocity = 5.0 // Скорость частицы
        cell.velocityRange = 10 // Разброс скорости
        cell.alphaSpeed = -0.5 // Частицы будут постепенно исчезать
        cell.yAcceleration = 5.0 // Эффект гравитации по оси Y
        cell.spin = CGFloat.pi // Вращение частиц
        cell.spinRange = CGFloat.pi * 2.0 // Разброс вращения
        return cell
    }
    
    // Создаем и настраиваем ячейку эмиттера
    // Вычисляем количество частиц на основе площади SpoilerView
    private func dotCount() -> Float {
        let area = frame.width * frame.height
        let densityFactor: Float = 0.07 // Настройте этот коэффициент для желаемой плотности
        let count = area * densityFactor
        return count
    }
}

Пояснения к коду:

●      layerClass: Переопределяем это свойство, чтобы SpoilerView использовал CAEmitterLayer в качестве своего слоя.

●      startAnimation (): Настраиваем параметры эмиттера и запускаем анимацию.

●      stopAnimation (): Останавливаем анимацию, удаляя ячейки эмиттера.

●      createEmitterCell (): Создаем и настраиваем ячейку эмиттера (CAEmitterCell), которая определяет свойства частиц (изображение, размер, скорость, время жизни и т.д.).

●      dotCount (): Вычисляем количество частиц на основе ширины SpoilerView, чтобы эффект выглядел одинаково на разных размерах.

Шаг 2: Создание ViewController и настройка UITextView

Теперь перейдем к контроллеру, в котором будем использовать SpoilerView для скрытия определенных частей текста в UITextView.

import UIKit

class ViewController: UIViewController, UIScrollViewDelegate {
    let textView = UITextView()
    var spoilerRanges: [NSRange] = []
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        setupTextView()
    }
    
    // Вызывается после установки размеров представлений
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()  
        setupSpoilers()
    }
    // Настраиваем UITextView и обрабатываем спойлеры в тексте
    func setupTextView() {
        textView.frame = CGRect(x: 20.0, y: 120.0, width: UIScreen.main.bounds.width - 40.0, height: 300.0)
        textView.isEditable = false
        textView.isScrollEnabled = true
        textView.font = UIFont.systemFont(ofSize: 18)
        textView.isSelectable = false
        textView.backgroundColor = .white
        textView.textColor = .black
        textView.text = "Это пример текста с [спойлером, который занимает несколько строк и демонстрирует работу с многострочным текстом], который мы хотим скрыть. А вот еще один [секретный текст]."
        view.addSubview(textView)
        let attributedText = NSMutableAttributedString(string: textView.text)
        // Ищем спойлеры в тексте с помощью регулярного выражения
        let pattern = "\\[([^\\]]+)\\]"
        let regex = try? NSRegularExpression(pattern: pattern, options: [])
        let matches = regex?.matches(in: textView.text, options: [], range: NSRange(location: 0, length: textView.text.utf16.count)) ?? []

        for match in matches {
            // Скрываем скобки, устанавливая прозрачный цвет
            attributedText.addAttribute(.foregroundColor, value: UIColor.clear, range: NSRange(location: match.range.location, length: 1))
            attributedText.addAttribute(.foregroundColor, value: UIColor.clear, range: NSRange(location: match.range.location + match.range.length - 1, length: 1))

            // Добавляем диапазон спойлера без скобок в массив
            let spoilerRange = NSRange(location: match.range.location + 1, length: match.range.length - 2)
            spoilerRanges.append(spoilerRange)
        }

        textView.attributedText = attributedText
    }

Пояснения к коду:

  • textView: Создаем и настраиваем UITextView, в котором будет отображаться текст со спойлерами.

  • setupTextView (): Метод для настройки textView и обработки спойлеров.

  • Регулярное выражение: Используем для поиска текста, заключенного в квадратные скобки [ ], который будем считать спойлером

  • matches: Находим все совпадения спойлеров в тексте.

  • Скрытие скобок: Устанавливаем прозрачный цвет для скобок, чтобы они не отображались.

  • spoilerRanges: Сохраняем диапазоны спойлеров без скобок для дальнейшей обработки.

Шаг 3: Создание и настройка SpoilerView для каждого спойлера

Добавим метод setupSpoilers (), который создаст SpoilerView для каждого найденного спойлера и наложит его на соответствующий текст.

extension ViewController {
    func setupSpoilers() {
        let spoilerRectsArray = getRectsForSpoilerRanges()
        for rects in spoilerRectsArray {
            // Устанавливаем фрейм SpoilerView
            let unionRect = rects.reduce(rects.first!) { $0.union($1) }
            let spoilerView = SpoilerView(frame: unionRect)
            spoilerView.backgroundColor = .white
            textView.addSubview(spoilerView)
            
            // Создаем путь, объединяющий все прямоугольники спойлера, с учетом координат SpoilerView
            let combinedPath = UIBezierPath()
            for rect in rects {
                // Преобразуем координаты прямоугольников в систему координат SpoilerView
                let adjustedRect = rect.offsetBy(dx: -unionRect.origin.x, dy: -unionRect.origin.y)
                combinedPath.append(UIBezierPath(rect: adjustedRect))
            }
            
            // Создаем маску на основе объединенного пути
            let maskLayer = CAShapeLayer()
            maskLayer.path = combinedPath.cgPath
            maskLayer.frame = spoilerView.bounds // Устанавливаем фрейм маски равным bounds SpoilerView
            spoilerView.layer.mask = maskLayer
            
            // Запускаем анимацию
            spoilerView.startAnimation()
            
            // Добавляем распознаватель жестов для обработки нажатия на спойлер
            let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleSpoilerTap(_:)))
            spoilerView.addGestureRecognizer(tapGesture)
        }
    }

Пояснения к коду:

●      getRectsForSpoilerRanges (): Метод, который возвращает массив массивов CGRect для каждого спойлера. Эти прямоугольники соответствуют областям, где находится текст спойлера.

●      unionRect: Объединяем все прямоугольники спойлера в один общий прямоугольник, который будет фреймом для SpoilerView.

●      SpoilerView: Создаем SpoilerView с фреймом unionRect и добавляем его поверх textView.

●      Маска: Создаем маску (maskLayer) на основе объединенного пути из прямоугольников, чтобы SpoilerViewзакрывал только текст спойлера.

●      startAnimation (): Запускаем анимацию спойлера.

●      Распознаватель жестов: Добавляем UITapGestureRecognizer для обработки нажатия на спойлер и его раскрытия.

Шаг 4: Обработка нажатий на спойлер

Реализуем метод handleSpoilerTap (_:), который будет вызываться при нажатии на спойлер.

extension ViewController {
    @objc func handleSpoilerTap(_ sender: UITapGestureRecognizer) {
        if let spoilerView = sender.view as? SpoilerView {
            if spoilerView.emitterLayer.emitterCells == nil {
                // Если анимация не запущена, запускаем ее и устанавливаем белый фон
                spoilerView.startAnimation()
                spoilerView.backgroundColor = .white
            } else {
                // Иначе останавливаем анимацию и делаем фон прозрачным
                spoilerView.stopAnimation()
                spoilerView.backgroundColor = .clear
            }
        }
    }
}

Пояснения к коду:

●      Проверка состояния анимации: Если ячейки эмиттера отсутствуют (emitterCells == nil), значит анимация остановлена, и мы запускаем ее.

●      Изменение фона: Устанавливаем или убираем фон SpoilerView в зависимости от состояния спойлера.

●      startAnimation () и stopAnimation (): Управляем анимацией спойлера.

Шаг 5: Обработка прокрутки и обновление позиций спойлеров

Если UITextView является прокручиваемым, нам нужно обновлять позиции SpoilerView при прокрутке.

extension ViewController {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        updateSpoilerViewsPosition()
    }

    func updateSpoilerViewsPosition() {
        let spoilerRectsArray = getRectsForSpoilerRanges()
        var index = 0
        for subview in textView.subviews where subview is SpoilerView {
            let spoilerView = subview as! SpoilerView
            let rects = spoilerRectsArray[index]

            // Обновляем фрейм SpoilerView
            let unionRect = rects.reduce(rects.first!) { $0.union($1) }
            spoilerView.frame = unionRect

            // Обновляем маску
            let combinedPath = UIBezierPath()
            for rect in rects {
                let adjustedRect = rect.offsetBy(dx: -unionRect.origin.x, dy: -unionRect.origin.y)
                combinedPath.append(UIBezierPath(rect: adjustedRect))
            }
            let maskLayer = CAShapeLayer()
            maskLayer.path = combinedPath.cgPath
            maskLayer.frame = spoilerView.bounds
            spoilerView.layer.mask = maskLayer
            index += 1
        }
    }
}

Пояснения к коду:

●      scrollViewDidScroll (_:): Метод делегата UIScrollViewDelegate, который вызывается при прокрутке textView.

●      updateSpoilerViewsPosition (): Обновляем фреймы и маски всех SpoilerView на основе текущего положения текста.

●      Перебор спойлеров: Проходим по всем SpoilerView и обновляем их в соответствии с новыми позициями текста.

Шаг 6: Получение прямоугольников для спойлеров

Метод getRectsForSpoilerRanges () возвращает массив массивов CGRect, соответствующих областям спойлеров в UITextView.

extension ViewController {
    func getRectsForSpoilerRanges() -> [[CGRect]] {
        var rectsArray: [[CGRect]] = []

        for range in spoilerRanges {
            guard let start = textView.position(from: textView.beginningOfDocument, offset: range.location),
                  let end = textView.position(from: start, offset: range.length),
                  let textRange = textView.textRange(from: start, to: end) else {
                continue
            }

            let selectionRects = textView.selectionRects(for: textRange)
            var rects: [CGRect] = []
            for selectionRect in selectionRects {
                let rect = selectionRect.rect
                rects.append(rect)
            }
            rectsArray.append(rects)
        }
        return rectsArray
    }
}

Пояснения к коду:

●      Перебор spoilerRanges: Для каждого диапазона спойлера находим соответствующие позиции в textView.

●      selectionRects (for:): Получаем массив UITextSelectionRect, каждый из которых представляет прямоугольник выделения текста (учитывает переносы строк).

●      Сбор прямоугольников: Извлекаем CGRect из каждого UITextSelectionRect и добавляем в массив rects.

●      rectsArray: Массив массивов CGRect, где каждый внутренний массив соответствует одному спойлеру.

Заключение

Мы рассмотрели, как реализовать спойлер-эффект, похожий на Telegram, в iOS-приложении на Swift. Используя CAEmitterLayer, мы создали анимацию частиц, которая скрывает и раскрывает текст спойлера. Мы также разобрались, как работать с UITextView для определения диапазонов спойлеров и наложения SpoilerView поверх нужных частей текста.

Ключевые моменты:

●      Использование CAEmitterLayer: Позволяет создавать впечатляющие анимации частиц.

●      Работа с UITextView: Поиск и обработка определенных частей текста с помощью регулярных выражений и атрибутов текста.

●      Маскирование слоев: Применение маски к SpoilerView для отображения анимации только на области спойлера.

●      Обработка многострочных спойлеров: Учет переносов строк при определении областей спойлеров.

Спасибо за внимание! Надеюсь, эта статья была полезной и поможет вам в реализации спойлер-эффекта в ваших приложениях.

© Habrahabr.ru