Как замаскировать, анимировать и добавить тень к CAShapeLayer?

Как применить тень к каждому фрагменту, сохранив текущую анимацию? Я попробовал поместить его на сам слой, но тень не видна. Я также попробовал создать еще один слой с прозрачным фоном, но с чистым фоном тень не работает.

override func draw(_ rect: CGRect) {
        super.draw(rect)

        layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }

        let totalValue = data.reduce(0.0) { $0 + $1.value }
        let center = CGPoint(x: rect.midX, y: rect.midY)
        var startAngle: CGFloat = -CGFloat.pi / 2
        var cumulativeDelay: CFTimeInterval = 0

        gapSizeDegrees = data.count == 1 ? 0 : gapSizeDegrees

        data.forEach { value in
            let segmentValue = CGFloat(value.value)
            let color = value.color
            let endAngle = startAngle + (segmentValue / CGFloat(totalValue)) * 2 * .pi

            let outerRadius = rect.width / 2
            let outerStartAngle = startAngle + gapSizeDegrees.toRadians()
            let outerEndAngle = endAngle - gapSizeDegrees.toRadians()

            let path = UIBezierPath(arcCenter: center, radius: outerRadius, startAngle: outerStartAngle, endAngle: outerEndAngle, clockwise: true)
            let innerRadius = outerRadius - strokeWidth
            path.addArc(withCenter: center, radius: innerRadius, startAngle: outerEndAngle - gapSizeDegrees / rect.width, endAngle: outerStartAngle + gapSizeDegrees / rect.width , clockwise: false)
            path.close()

            let shapeLayer = CAShapeLayer()
            shapeLayer.path = path.cgPath
            shapeLayer.fillColor = color.cgColor
            shapeLayer.strokeColor = color.cgColor

            layer.addSublayer(shapeLayer)

            animateSliceFilling(sliceLayer: shapeLayer, center: center, radius: outerRadius, innerRadius: innerRadius, startAngle: startAngle, endAngle: endAngle, delay: cumulativeDelay)

            cumulativeDelay += Consts.Appearence.animationDuration / Double(data.count)

            startAngle = endAngle
        }
    }

    private func animateSliceFilling(sliceLayer: CAShapeLayer, center: CGPoint, radius: CGFloat, innerRadius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, delay: CFTimeInterval) {
        let maskLayer = CAShapeLayer()
        let maskPath = UIBezierPath(arcCenter: center, radius: (radius + innerRadius) / 2, startAngle: startAngle, endAngle: endAngle, clockwise: true)
        maskLayer.path = maskPath.cgPath
        maskLayer.fillColor = UIColor.clear.cgColor
        maskLayer.strokeColor = UIColor.black.cgColor
        maskLayer.lineWidth = strokeWidth
        maskLayer.strokeEnd = 0.0

        sliceLayer.mask = maskLayer

        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 0
        animation.toValue = 1
        animation.duration = Consts.Appearence.animationDuration / Double(data.count)
        animation.beginTime = CACurrentMediaTime() + delay
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false

        maskLayer.add(animation, forKey: "strokeEndAnimation")
    }

Вы пытаетесь пойти на что-то подобное? i.sstatic.net/QcTEu.gif

DonMag 26.02.2024 21:22

Да, именно это я и пытаюсь сделать, с той лишь разницей, что тень должна быть всегда черной.

user13965197 26.02.2024 21:28

@pistifeju - что значит «тень всегда должна быть черной»?

DonMag 26.02.2024 21:41

Возможно, качество присланной вами GIF-ки плохое, но на моем мониторе тень как бы желтоватая или немного меняется. Это не имеет значения; Мне просто нужна простая черная тень под моими фигурами.

user13965197 26.02.2024 21:47

Не стоит пытаться делать анимацию с помощью draw(_:). Вместо этого вам следует настроить слои как CALayers, а затем добавить к слоям один или несколько CAAnimation. Вероятно, вы могли бы настроить свои слои так, чтобы они имели тени, а затем анимировать обводку на ваших путях, и система отрисовывает тени за вас.

Duncan C 26.02.2024 22:10

Пожалуйста, не портите свои посты. Размещая информацию в сети Stack Exchange, вы предоставляете SE безотзывное право на распространение этого контента (по лицензии CC BY-SA 4.0). Согласно политике SE, любой вандализм будет пресечен.

tripleee 30.04.2024 10:11
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
6
129
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Несколько наблюдений:

  1. Я бы поместил тень на отдельный слой (в случае, если тень больше угла зазора; вы хотите убедиться, что тень одного слоя не перекрывает данные другого слоя).

  2. Я бы создал слои-фигуры для слоя тени; и каждый из слоев данных.

  3. Затем я бы анимировал маску на всем родительском слое.

  4. Нам не следует добавлять слои или выполнять анимацию из draw(rect:). (Это используется при рендеринге вашей собственной анимации и для рендеринга одного кадра; но мы полагаемся, что CoreGraphics сделает анимацию за нас, поэтому draw(rect:) — это просто не подходящее место для создания этой анимации.

    Использование layoutSubviews было бы более логичным.

  5. Кроме того, всякий раз, когда вы реализуете draw(rect:) (чего мы здесь делать не будем), никогда не предполагайте, что rect представляет собой полное представление. Он показывает, какая часть представления рисуется. Вместо этого используйте bounds.

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

  7. Я был бы склонен визуализировать каждую дугу как одну дугу (не внутреннюю в одном направлении и внешнюю дугу в другом), если только вам это абсолютно не нужно (например, вы хотите сделать какое-то скругление углов самих дуг). Это не критично к данному вопросу, но немного упрощает код.

  8. Я бы вычислил радиус по минимуму ширины и высоты.

Это дает:

class AnimatedDataView: UIView {
    let strokeWidth: CGFloat = 30
    var gapSizeDegrees: CGFloat = 5
    let shadowRadius: CGFloat = 5

    var data: [Value] = [
        Value(value: 0.25, color: .red),
        Value(value: 0.33, color: .green),
        Value(value: 1 - 0.25 - 0.33, color: .blue)
    ]

    override func layoutSubviews() {
        super.layoutSubviews()

        start()
    }

    func start() {
        let rect = bounds

        layer.mask = nil
        layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }

        let totalValue = data.reduce(0) { $0 + $1.value }
        let center = CGPoint(x: rect.midX, y: rect.midY)
        var angle: CGFloat = -.pi / 2
        var cumulativeDelay: CFTimeInterval = 0

        gapSizeDegrees = data.count == 1 ? 0 : gapSizeDegrees
        let radius = min(rect.width, rect.height) / 2 - strokeWidth / 2

        let shadowPath = UIBezierPath()

        let shadowLayer = CAShapeLayer()
        shadowLayer.fillColor = UIColor.clear.cgColor
        shadowLayer.strokeColor = UIColor.black.cgColor
        shadowLayer.lineWidth = strokeWidth
        shadowLayer.shadowOpacity = 1
        shadowLayer.shadowRadius = shadowRadius
        layer.addSublayer(shadowLayer)

        data.forEach { value in
            let segmentValue = CGFloat(value.value)
            let color = value.color
            let delta = (segmentValue / CGFloat(totalValue)) * 2 * .pi
            let endAngle = angle + delta - gapSizeDegrees.toRadians() / 2

            let startAngle = angle + gapSizeDegrees.toRadians() / 2

            let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
            shadowPath.append(path)

            let shapeLayer = CAShapeLayer()
            shapeLayer.path = path.cgPath
            shapeLayer.fillColor = UIColor.clear.cgColor
            shapeLayer.strokeColor = color.cgColor
            shapeLayer.lineWidth = strokeWidth

            layer.addSublayer(shapeLayer)

            cumulativeDelay += Consts.Appearence.animationDuration / Double(data.count)

            angle += delta
        }

        shadowLayer.path = shadowPath.cgPath

        let maskLayer = CAShapeLayer()
        let maskPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: 3 * .pi / 2, clockwise: true)
        maskLayer.path = maskPath.cgPath
        maskLayer.fillColor = UIColor.clear.cgColor
        maskLayer.strokeColor = UIColor.black.cgColor
        maskLayer.lineWidth = strokeWidth + shadowRadius

        layer.mask = maskLayer

        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 0
        animation.toValue = 1
        animation.duration = Consts.Appearence.animationDuration / Double(data.count)
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false

        maskLayer.add(animation, forKey: "strokeEndAnimation")
    }
}

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

class AnimatedDataView: UIView {
    let arcWidth: CGFloat = 150
    let cornerRadius: CGFloat = 20
    var gapSize: CGFloat = 8
    let shadowRadius: CGFloat = 4

    var data: [Value] = [
        Value(value: 0.25, color: .red),
        Value(value: 0.33, color: .green),
        Value(value: 1 - 0.25 - 0.33, color: .blue)
    ]

    override func layoutSubviews() {
        super.layoutSubviews()

        start()
    }

    func start() {
        layer.mask = nil
        layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }

        let totalValue = data.reduce(0) { $0 + $1.value }
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        var angle: CGFloat = -.pi / 2
        
        let gapSize = data.count == 1 ? 0 : self.gapSize
        let radius = (min(bounds.width, bounds.height) - arcWidth) / 2

        let shadowPath = UIBezierPath()

        let shadowLayer = CAShapeLayer()
        shadowLayer.fillColor = UIColor.black.cgColor
        shadowLayer.strokeColor = UIColor.black.cgColor
        shadowLayer.lineWidth = cornerRadius * 2
        shadowLayer.lineJoin = .round
        shadowLayer.shadowOpacity = 1
        shadowLayer.shadowRadius = shadowRadius
        shadowLayer.shadowOffset = .zero
        layer.addSublayer(shadowLayer)

        data.forEach { value in
            let segmentValue = CGFloat(value.value)
            let color = value.color
            let delta = (segmentValue / CGFloat(totalValue)) * 2 * .pi
            let endAngle = angle + delta

            let path = UIBezierPath(center: center, startAngle: angle, endAngle: endAngle, radius: radius, arcWidth: arcWidth, spacing: gapSize, cornerRadius: cornerRadius)
            shadowPath.append(path)

            let shapeLayer = CAShapeLayer()
            shapeLayer.path = path.cgPath
            shapeLayer.fillColor = color.cgColor
            shapeLayer.strokeColor = color.cgColor
            shapeLayer.lineWidth = cornerRadius * 2
            shapeLayer.lineJoin = .round

            layer.addSublayer(shapeLayer)

            angle = endAngle
        }

        shadowLayer.path = shadowPath.cgPath

        let maskStrokeWidth = sqrt(bounds.width * bounds.width + bounds.height * bounds.height)
        let maskLayer = CAShapeLayer()
        maskLayer.path = UIBezierPath(arcCenter: center, radius: maskStrokeWidth / 2, startAngle: -.pi / 2, endAngle: 3 * .pi / 2, clockwise: true).cgPath
        maskLayer.fillColor = UIColor.clear.cgColor
        maskLayer.strokeColor = UIColor.black.cgColor
        maskLayer.lineWidth = maskStrokeWidth

        layer.mask = maskLayer

        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 0
        animation.toValue = 1
        animation.duration = Consts.Appearance.animationDuration
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false

        maskLayer.add(animation, forKey: "strokeEndAnimation")
    }
}

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

В любом случае, здесь используется расширение UIBezierPath:

extension UIBezierPath {
    /// Create arc path with corner rounding and spacing between arcs.
    ///
    /// Note, this path assumes that the “corner radius” will be achieved by setting the `lineWidth` to
    /// two times the “corner radius”. We're setting that lineWidth here, but if using in a `CAShapeLayer`
    /// you will need to explicitly set that `lineWidth` for that shape layer, too.
    ///
    /// - Parameters:
    ///     - center: Center of the arc.
    ///     - startAngle: The start angle of the arc. Note, this will be inset by 1/2 of the `spacing` value.
    ///     - endAngle: The end angle of the arc. Note, this will be inset by 1/2 of the `spacing` value.
    ///     - clockwise: Should this be rendered clockwise or counter clockwise. Defaults to `true`.
    ///     - radius: The radius of the arc (the midpoint between the outer edge of the arc and the inner edge).
    ///     - arcWidth: The width of this arc.
    ///     - spacing: The spacing between this arc and the next. This arc is inset by half of this spacing at the start and end of the arc. Defaults to zero.
    ///     - cornerRadius: The corner radius of this thick arc. Defaults to zero.
        
    convenience init(
        center: CGPoint,
        startAngle: CGFloat,
        endAngle: CGFloat,
        clockwise: Bool = true,
        radius: CGFloat,
        arcWidth: CGFloat, 
        spacing: CGFloat = 0,
        cornerRadius: CGFloat = 0
    ) {
        let innerRadius = radius - arcWidth / 2 + cornerRadius
        let innerAngularDelta = asin((cornerRadius + spacing / 2) / innerRadius) * (clockwise ? 1 : -1)
        let outerRadius = radius + arcWidth / 2 - cornerRadius
        let outerAngularDelta = asin((cornerRadius + spacing / 2) / outerRadius) * (clockwise ? 1 : -1)
        
        self.init(arcCenter: center, radius: innerRadius, startAngle: startAngle + innerAngularDelta, endAngle: endAngle - innerAngularDelta, clockwise: clockwise)
        
        addArc(withCenter: center, radius: outerRadius, startAngle: endAngle - outerAngularDelta, endAngle: startAngle + outerAngularDelta, clockwise: !clockwise)
        
        lineWidth = cornerRadius * 2
        
        close()
    }
}

Это дает:

(Обратите внимание: я преувеличил ширину этих дуг, чтобы было ясно видно расстояние между параллельными линиями.)

Спасибо, это работает, а также спасибо за наблюдения. Изначально я хотел сделать одну дугу, но мне также нужны закругленные углы, а с одной дугой я не смог найти способ сделать промежутки параллельными между срезами. (Ширина зазора была меньше, когда она была ближе к внутреннему радиусу, но шире, когда она была ближе к внешнему радиусу.)

user13965197 26.02.2024 22:43

Буду признателен, если вы сделаете версию, где зазоры параллельны, спасибо!

user13965197 27.02.2024 00:17

Возможно ли, что в последнем коде есть ошибка, которая делает закругленные углы и параллельные зазоры? Скопировал полный код и добавил его в качестве подпредставления в viewController, но диаграмму вообще не вижу. Если я установлю фоновый цвет для представления, я увижу прямоугольник. Сделал то же самое с другим кодом, который вы отправили ранее, и он работает нормально.

user13965197 27.02.2024 17:52

Интересный. Это то, что я делаю, и ничего не вижу на экране. Я что-то пропустил? Пытался вернуться до маскировки, но это не помогло. class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let dataView = AnimatedDataView(frame: CGRect(x: 50, y: 50, width: 250, height: 250)) view.addSubview(dataView) } }

user13965197 27.02.2024 18:24

Давайте продолжим обсуждение в чате.

user13965197 27.02.2024 19:28

Другие вопросы по теме