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

от автора

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

Мухаммадиер Расулов

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

Цель статьи

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

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

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

Содержание

Шаг 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 для отображения анимации только на области спойлера.

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/847236/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *