diff --git a/snap_drawing/src/snap_drawing/cpp/Layers/ShapeLayer.cpp b/snap_drawing/src/snap_drawing/cpp/Layers/ShapeLayer.cpp index bccbe523..0ca952e8 100644 --- a/snap_drawing/src/snap_drawing/cpp/Layers/ShapeLayer.cpp +++ b/snap_drawing/src/snap_drawing/cpp/Layers/ShapeLayer.cpp @@ -28,7 +28,13 @@ void ShapeLayer::onInitialize() { void ShapeLayer::onDraw(DrawingContext& drawingContext) { const auto& path = getActivePath(); - drawingContext.drawPaint(_fillPaint, path); + auto fillPaint = _fillPaint; + if (_fillGradientWrapper.hasGradient()) { + fillPaint.setColor(Color::black()); + _fillGradientWrapper.update(path.getBounds().value_or(drawingContext.drawBounds())); + _fillGradientWrapper.applyToPaint(fillPaint); + } + drawingContext.drawPaint(fillPaint, path); drawingContext.drawPaint(_strokePaint, path); } @@ -77,6 +83,51 @@ void ShapeLayer::setFillColor(Color fillColor) { setNeedsDisplay(); } +void ShapeLayer::setFillLinearGradient(std::vector&& locations, + std::vector&& colors, + LinearGradientOrientation orientation) { + if (_fillGradientWrapper.clearIfNeeded(GradientWrapper::GradientType::RADIAL)) { + setNeedsDisplay(); + } + + if (colors.empty()) { + if (_fillGradientWrapper.clearIfNeeded(GradientWrapper::GradientType::LINEAR)) { + setNeedsDisplay(); + } + return; + } + + _fillGradientWrapper.setAsLinear(std::move(locations), std::move(colors), orientation); + + if (_fillGradientWrapper.isDirty()) { + setNeedsDisplay(); + } +} + +void ShapeLayer::setFillRadialGradient(std::vector&& locations, std::vector&& colors) { + if (_fillGradientWrapper.clearIfNeeded(GradientWrapper::GradientType::LINEAR)) { + setNeedsDisplay(); + } + + if (colors.empty()) { + if (_fillGradientWrapper.clearIfNeeded(GradientWrapper::GradientType::RADIAL)) { + setNeedsDisplay(); + } + return; + } + + _fillGradientWrapper.setAsRadial(std::move(locations), std::move(colors)); + + if (_fillGradientWrapper.isDirty()) { + setNeedsDisplay(); + } +} + +void ShapeLayer::resetFillGradient() { + _fillGradientWrapper.clear(); + setNeedsDisplay(); +} + void ShapeLayer::setStrokeCap(PaintStrokeCap strokeCap) { _strokePaint.setStrokeCap(strokeCap); setNeedsDisplay(); diff --git a/snap_drawing/src/snap_drawing/cpp/Layers/ShapeLayer.hpp b/snap_drawing/src/snap_drawing/cpp/Layers/ShapeLayer.hpp index 798c678d..9a8384f0 100644 --- a/snap_drawing/src/snap_drawing/cpp/Layers/ShapeLayer.hpp +++ b/snap_drawing/src/snap_drawing/cpp/Layers/ShapeLayer.hpp @@ -9,9 +9,12 @@ #include "snap_drawing/cpp/Drawing/Paint.hpp" #include "snap_drawing/cpp/Layers/Layer.hpp" +#include "snap_drawing/cpp/Utils/GradientWrapper.hpp" #include "snap_drawing/cpp/Utils/Path.hpp" #include "snap_drawing/cpp/Utils/PathInterpolator.hpp" +#include + namespace snap::drawing { class MaskFilter; @@ -34,6 +37,12 @@ class ShapeLayer : public Layer { void setFillColor(Color fillColor); Color getFillColor() const; + void setFillLinearGradient(std::vector&& locations, + std::vector&& colors, + LinearGradientOrientation orientation); + void setFillRadialGradient(std::vector&& locations, std::vector&& colors); + void resetFillGradient(); + void setStrokeCap(PaintStrokeCap strokeCap); void setStrokeJoin(PaintStrokeJoin strokeJoin); @@ -52,6 +61,7 @@ class ShapeLayer : public Layer { Path _path; Paint _strokePaint; Paint _fillPaint; + GradientWrapper _fillGradientWrapper; Scalar _strokeStart = 0.0f; Scalar _strokeEnd = 1.0f; std::optional _pathInterpolator; diff --git a/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts b/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts index 1a77455f..2758817d 100644 --- a/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts +++ b/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts @@ -2168,6 +2168,11 @@ export interface ShapeView extends _ShapeView, LeafView { */ fillColor?: Color; + /** + * Gradient that the shape's enclosed area is filled with. + */ + fillGradient?: string; + /** * The stroke cap specifies the shape of the endpoints of an open path when stroked. */ diff --git a/valdi/src/java/com/snap/valdi/attributes/impl/ShapeViewAttributesBinder.kt b/valdi/src/java/com/snap/valdi/attributes/impl/ShapeViewAttributesBinder.kt index d1f76998..2d0344b8 100644 --- a/valdi/src/java/com/snap/valdi/attributes/impl/ShapeViewAttributesBinder.kt +++ b/valdi/src/java/com/snap/valdi/attributes/impl/ShapeViewAttributesBinder.kt @@ -6,6 +6,7 @@ import com.snap.valdi.attributes.AttributesBindingContext import com.snap.valdi.attributes.impl.animations.ValdiAnimator import com.snap.valdi.attributes.impl.animations.ValdiValueAnimation import com.snap.valdi.attributes.impl.animations.ViewAnimator +import com.snap.valdi.attributes.impl.gradients.ValdiGradient import com.snap.valdi.exceptions.AttributeError import com.snap.valdi.extensions.ViewUtils import com.snap.valdi.utils.CoordinateResolver @@ -52,6 +53,23 @@ class ShapeViewAttributesBinder : AttributesBinder { view.resetFillColor() } + private fun applyFillGradient(view: ShapeView, value: Array, animator: ValdiAnimator?) { + val valdiGradient = ValdiGradient.fromGradientData(value) + + if (valdiGradient.colors.isEmpty()) { + view.resetFillGradient() + } else if (valdiGradient.colors.size == 1) { + view.resetFillGradient() + view.setFillColor(valdiGradient.colors[0]) + } else { + view.setFillGradient(valdiGradient) + } + } + + private fun resetFillGradient(view: ShapeView, animator: ValdiAnimator?) { + view.resetFillGradient() + } + private fun applyStrokeCap(view: ShapeView, value: String, animator: ValdiAnimator?) { val strokeCap: Paint.Cap = when (value) { "butt" -> Paint.Cap.BUTT @@ -119,6 +137,7 @@ class ShapeViewAttributesBinder : AttributesBinder { attributesBindingContext.bindFloatAttribute("strokeWidth", false, this::applyStrokeWidth, this::resetStrokeWidth) attributesBindingContext.bindColorAttribute("strokeColor", false, this::applyStrokeColor, this::resetStrokeColor) attributesBindingContext.bindColorAttribute("fillColor", false, this::applyFillColor, this::resetFillColor) + attributesBindingContext.bindArrayAttribute("fillGradient", false, this::applyFillGradient, this::resetFillGradient) attributesBindingContext.bindStringAttribute("strokeCap", false, this::applyStrokeCap, this::resetStrokeCap) attributesBindingContext.bindStringAttribute("strokeJoin", false, this::applyStrokeJoin, this::resetStrokeJoin) attributesBindingContext.bindFloatAttribute("strokeStart", false, this::applyStrokeStart, this::resetStrokeStart) diff --git a/valdi/src/java/com/snap/valdi/views/ShapeView.kt b/valdi/src/java/com/snap/valdi/views/ShapeView.kt index f7979791..4b8411a4 100644 --- a/valdi/src/java/com/snap/valdi/views/ShapeView.kt +++ b/valdi/src/java/com/snap/valdi/views/ShapeView.kt @@ -4,13 +4,20 @@ import androidx.annotation.Keep import android.content.Context import android.graphics.Canvas import android.graphics.Color +import android.graphics.LinearGradient import android.graphics.Paint import android.graphics.Path +import android.graphics.RadialGradient +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.drawable.GradientDrawable import android.view.View +import com.snap.valdi.attributes.impl.gradients.ValdiGradient import com.snap.valdi.extensions.ViewUtils import com.snap.valdi.utils.CoordinateResolver import com.snap.valdi.utils.GeometricPath import com.snap.valdi.utils.PathInterpolator +import kotlin.math.min private val DEFAULT_STROKE_WIDTH = 1.0f private val DEFAULT_COLOR = Color.TRANSPARENT @@ -41,8 +48,14 @@ class ShapeView(context: Context) : View(context), ValdiRecyclableView { private val geometricPath = GeometricPath() private val strokePaint = Paint() private val fillPaint = Paint() + private val fillGradientBounds = RectF() private val coordinateResolver = CoordinateResolver(context) private var pathInterpolator: PathInterpolator? = null + private var fillColor = DEFAULT_COLOR + private var fillGradient: ValdiGradient? = null + private var fillGradientDirty = false + private var fillGradientWidth = -1 + private var fillGradientHeight = -1 init { strokePaint.strokeJoin = Paint.Join.MITER @@ -86,7 +99,10 @@ class ShapeView(context: Context) : View(context), ValdiRecyclableView { } fun setFillColor(fillColor: Int) { - fillPaint.color = fillColor + this.fillColor = fillColor + if (fillGradient == null) { + fillPaint.color = fillColor + } invalidate() } @@ -94,6 +110,22 @@ class ShapeView(context: Context) : View(context), ValdiRecyclableView { setFillColor(DEFAULT_COLOR) } + fun setFillGradient(fillGradient: ValdiGradient) { + this.fillGradient = fillGradient + fillGradientDirty = true + invalidate() + } + + fun resetFillGradient() { + fillGradient = null + fillGradientDirty = false + fillGradientWidth = -1 + fillGradientHeight = -1 + fillPaint.color = fillColor + fillPaint.shader = null + invalidate() + } + fun setStrokeJoin(strokeJoin: Paint.Join) { strokePaint.strokeJoin = strokeJoin invalidate() @@ -122,11 +154,114 @@ class ShapeView(context: Context) : View(context), ValdiRecyclableView { val activePath = this.getActivePath() + if (fillGradient != null) { + fillPaint.color = Color.BLACK + updateFillGradientShader() + } else { + fillPaint.color = fillColor + fillPaint.shader = null + } + it.drawPath(activePath, fillPaint) it.drawPath(activePath, strokePaint) } } + private fun updateFillGradientShader() { + val gradient = fillGradient ?: return + if (!fillGradientDirty && fillGradientWidth == width && fillGradientHeight == height) { + return + } + + fillGradientDirty = false + fillGradientWidth = width + fillGradientHeight = height + fillGradientBounds.set(0.0f, 0.0f, width.toFloat(), height.toFloat()) + + when (gradient.getDrawableGradientType()) { + GradientDrawable.LINEAR_GRADIENT -> { + val x0: Float + val x1: Float + val y0: Float + val y1: Float + + when (gradient.getDrawableOrientation()) { + GradientDrawable.Orientation.TOP_BOTTOM -> { + x0 = fillGradientBounds.left + y0 = fillGradientBounds.top + x1 = x0 + y1 = fillGradientBounds.bottom + } + GradientDrawable.Orientation.TR_BL -> { + x0 = fillGradientBounds.right + y0 = fillGradientBounds.top + x1 = fillGradientBounds.left + y1 = fillGradientBounds.bottom + } + GradientDrawable.Orientation.RIGHT_LEFT -> { + x0 = fillGradientBounds.right + y0 = fillGradientBounds.top + x1 = fillGradientBounds.left + y1 = y0 + } + GradientDrawable.Orientation.BR_TL -> { + x0 = fillGradientBounds.right + y0 = fillGradientBounds.bottom + x1 = fillGradientBounds.left + y1 = fillGradientBounds.top + } + GradientDrawable.Orientation.BOTTOM_TOP -> { + x0 = fillGradientBounds.left + y0 = fillGradientBounds.bottom + x1 = x0 + y1 = fillGradientBounds.top + } + GradientDrawable.Orientation.BL_TR -> { + x0 = fillGradientBounds.left + y0 = fillGradientBounds.bottom + x1 = fillGradientBounds.right + y1 = fillGradientBounds.top + } + GradientDrawable.Orientation.LEFT_RIGHT -> { + x0 = fillGradientBounds.left + y0 = fillGradientBounds.top + x1 = fillGradientBounds.right + y1 = y0 + } + GradientDrawable.Orientation.TL_BR -> { + x0 = fillGradientBounds.left + y0 = fillGradientBounds.top + x1 = fillGradientBounds.right + y1 = fillGradientBounds.bottom + } + } + + fillPaint.shader = LinearGradient( + x0, + y0, + x1, + y1, + gradient.colors, + gradient.locations, + Shader.TileMode.CLAMP, + ) + } + GradientDrawable.RADIAL_GRADIENT -> { + fillPaint.shader = RadialGradient( + fillGradientBounds.centerX(), + fillGradientBounds.centerY(), + min(fillGradientBounds.width(), fillGradientBounds.height()) / 2.0f, + gradient.colors, + gradient.locations, + Shader.TileMode.CLAMP, + ) + } + else -> { + fillPaint.shader = null + } + } + } + private fun getActivePath(): Path { geometricPath.width = width geometricPath.height = height @@ -152,4 +287,4 @@ class ShapeView(context: Context) : View(context), ValdiRecyclableView { return ViewUtils.hasOverlappingRendering(this) } -} \ No newline at end of file +} diff --git a/valdi/src/valdi/ios/Views/SCValdiShapeView.m b/valdi/src/valdi/ios/Views/SCValdiShapeView.m index 743088d5..2325431c 100644 --- a/valdi/src/valdi/ios/Views/SCValdiShapeView.m +++ b/valdi/src/valdi/ios/Views/SCValdiShapeView.m @@ -10,9 +10,18 @@ #import "valdi_core/SCValdiLogger.h" #import "valdi/ios/SCValdiAttributesBinder.h" #import "valdi/ios/Categories/UIView+Valdi.h" +#import "valdi/ios/Utils/GradientUtils.h" #import "valdi_core/SCValdiGeometricPath.h" -@implementation SCValdiShapeView +static NSString *const kFillGradientLayoutKey = @"shape_fill_gradient"; + +@implementation SCValdiShapeView { + UIColor *_fillColor; + UIColor *_strokeColor; + CAGradientLayer *_fillGradientLayer; + CAShapeLayer *_fillGradientMaskLayer; + CAShapeLayer *_fillGradientStrokeLayer; +} - (instancetype)initWithFrame:(CGRect)frame { @@ -38,8 +47,18 @@ - (void)valdi_applyPath:(id)pathData animator:(id)anima [animator addAnimationOnLayer:[self shapeLayer] forKeyPath:@"path" value:(__bridge id)pathRef]; + if (_fillGradientMaskLayer) { + [animator addAnimationOnLayer:_fillGradientMaskLayer + forKeyPath:@"path" + value:(__bridge id)pathRef]; + [animator addAnimationOnLayer:_fillGradientStrokeLayer + forKeyPath:@"path" + value:(__bridge id)pathRef]; + } } else { [self shapeLayer].path = pathRef; + _fillGradientMaskLayer.path = pathRef; + _fillGradientStrokeLayer.path = pathRef; } if (pathRef) { @@ -62,8 +81,14 @@ - (void)valdi_setStrokeStart:(CGFloat)strokeStart animator:(id)animator +{ + if (!_fillGradientLayer) { + return; + } + + CGRect bounds = self.layer.bounds; + if (animator) { + CGPoint center = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds)); + NSValue *position = [NSValue valueWithCGPoint:center]; + NSValue *layerBounds = [NSValue valueWithCGRect:CGRectMake(0, 0, bounds.size.width, bounds.size.height)]; + + for (CALayer *layer in @[_fillGradientLayer, _fillGradientMaskLayer, _fillGradientStrokeLayer]) { + [animator addAnimationOnLayer:layer forKeyPath:@"position" value:position]; + [animator addAnimationOnLayer:layer forKeyPath:@"bounds" value:layerBounds]; + } + } else { + _fillGradientLayer.frame = bounds; + _fillGradientMaskLayer.frame = _fillGradientLayer.bounds; + _fillGradientStrokeLayer.frame = bounds; + } +} + +- (void)_resetFillGradientWithAnimator:(id)animator +{ + [_fillGradientLayer removeFromSuperlayer]; + [_fillGradientStrokeLayer removeFromSuperlayer]; + _fillGradientLayer = nil; + _fillGradientMaskLayer = nil; + _fillGradientStrokeLayer = nil; + + [self.valdiViewNode setDidFinishLayoutBlock:nil forKey:kFillGradientLayoutKey]; + [self _setFillColor:_fillColor animator:animator]; + [self _setStrokeColor:_strokeColor animator:animator]; +} + +- (BOOL)valdi_setFillGradient:(NSArray *)attributeValue animator:(id)animator +{ + NSArray *colors = attributeValue.firstObject; + if (colors.count < 2) { + if (colors.count == 1) { + _fillColor = UIColorFromValdiAttributeValue((int64_t)[colors.firstObject integerValue]); + } + [self _resetFillGradientWithAnimator:animator]; + return YES; + } + + CAShapeLayer *shapeLayer = [self shapeLayer]; + if (!_fillGradientLayer) { + _fillGradientLayer = [CAGradientLayer layer]; + + _fillGradientMaskLayer = [CAShapeLayer layer]; + _fillGradientMaskLayer.fillColor = UIColor.blackColor.CGColor; + _fillGradientMaskLayer.path = shapeLayer.path; + _fillGradientLayer.mask = _fillGradientMaskLayer; + + _fillGradientStrokeLayer = [CAShapeLayer layer]; + _fillGradientStrokeLayer.fillColor = nil; + _fillGradientStrokeLayer.path = shapeLayer.path; + _fillGradientStrokeLayer.strokeColor = shapeLayer.strokeColor; + _fillGradientStrokeLayer.lineWidth = shapeLayer.lineWidth; + _fillGradientStrokeLayer.lineCap = shapeLayer.lineCap; + _fillGradientStrokeLayer.lineJoin = shapeLayer.lineJoin; + _fillGradientStrokeLayer.strokeStart = shapeLayer.strokeStart; + _fillGradientStrokeLayer.strokeEnd = shapeLayer.strokeEnd; + + shapeLayer.fillColor = nil; + shapeLayer.strokeColor = nil; + [shapeLayer insertSublayer:_fillGradientLayer atIndex:0]; + [shapeLayer insertSublayer:_fillGradientStrokeLayer above:_fillGradientLayer]; } + + setUpGradientLayerForRawAttributes(attributeValue, _fillGradientLayer); + [self _layoutFillGradientWithAnimator:animator]; + + [self.valdiViewNode setDidFinishLayoutBlock:^(SCValdiShapeView *view, id animator) { + [view _layoutFillGradientWithAnimator:animator]; + } forKey:kFillGradientLayoutKey]; + + return YES; } + (void)bindAttributes:(id)attributesBinder @@ -114,6 +227,14 @@ + (void)bindAttributes:(id)attributesBinder resetBlock:^(SCValdiShapeView *view, id animator) { [view _setFillColor:nil animator:animator]; }]; + [attributesBinder bindAttribute:@"fillGradient" + invalidateLayoutOnChange:NO + withArrayBlock:^BOOL(SCValdiShapeView *view, NSArray *attributeValue, id animator) { + return [view valdi_setFillGradient:attributeValue animator:animator]; + } + resetBlock:^(SCValdiShapeView *view, id animator) { + [view _resetFillGradientWithAnimator:animator]; + }]; [attributesBinder bindAttribute:@"strokeColor" invalidateLayoutOnChange:NO withColorBlock:^BOOL(SCValdiShapeView *view, UIColor *attributeValue, id animator) { @@ -162,6 +283,12 @@ - (BOOL)requiresLayoutWhenAnimatingBounds return NO; } +- (void)layoutSubviews +{ + [super layoutSubviews]; + [self _layoutFillGradientWithAnimator:nil]; +} + #pragma mark - Shape layer - (CAShapeLayer *)shapeLayer @@ -176,6 +303,11 @@ + (Class)layerClass - (void)_setFillColor:(UIColor *)color animator:(id )animator { + _fillColor = color; + if (_fillGradientLayer) { + return; + } + if (animator) { [animator addAnimationOnLayer:self.shapeLayer forKeyPath:NSStringFromSelector(@selector(fillColor)) value:(__bridge id)color.CGColor]; } else { @@ -185,10 +317,15 @@ - (void)_setFillColor:(UIColor *)color animator:(id )an - (void)_setStrokeColor:(UIColor *)color animator:(id )animator { + _strokeColor = color; + CAShapeLayer *strokeLayer = _fillGradientStrokeLayer ?: self.shapeLayer; + if (animator) { - [animator addAnimationOnLayer:self.shapeLayer forKeyPath:NSStringFromSelector(@selector(strokeColor)) value:(__bridge id)color.CGColor]; + [animator addAnimationOnLayer:strokeLayer + forKeyPath:NSStringFromSelector(@selector(strokeColor)) + value:(__bridge id)color.CGColor]; } else { - self.shapeLayer.strokeColor = color.CGColor; + strokeLayer.strokeColor = color.CGColor; } } @@ -196,8 +333,14 @@ - (void)_setLineWidth:(CGFloat)lineWidth animator:(id ) { if (animator) { [animator addAnimationOnLayer:self.shapeLayer forKeyPath:NSStringFromSelector(@selector(lineWidth)) value:@(lineWidth)]; + if (_fillGradientStrokeLayer) { + [animator addAnimationOnLayer:_fillGradientStrokeLayer + forKeyPath:NSStringFromSelector(@selector(lineWidth)) + value:@(lineWidth)]; + } } else { self.shapeLayer.lineWidth = lineWidth; + _fillGradientStrokeLayer.lineWidth = lineWidth; } } @@ -216,6 +359,7 @@ - (BOOL)valdi_setStrokeCap:(NSString *)strokeCap } } [self shapeLayer].lineCap = lineCap; + _fillGradientStrokeLayer.lineCap = lineCap; return YES; } @@ -234,6 +378,7 @@ - (BOOL)valdi_setStrokeJoin:(NSString *)strokeJoin } } [self shapeLayer].lineJoin = lineJoin; + _fillGradientStrokeLayer.lineJoin = lineJoin; return YES; } diff --git a/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp b/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp index ff1827e6..cdb172c9 100644 --- a/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp +++ b/valdi/src/valdi/runtime/Attributes/DefaultAttributeProcessors.cpp @@ -394,6 +394,7 @@ void registerDefaultProcessors(AttributesManager& attributesManager) { auto borderRadiusAttributeId = attributesManager.getAttributeIds().getIdForName("borderRadius"); auto textGradientAttributeId = attributesManager.getAttributeIds().getIdForName("textGradient"); auto maskImageAttributeId = attributesManager.getAttributeIds().getIdForName("maskImage"); + auto fillGradientAttributeId = attributesManager.getAttributeIds().getIdForName("fillGradient"); attributesManager.registerPreprocessor(borderAttributeId, &preprocessBorder); attributesManager.registerPreprocessor(boxShadowAttributeId, &preprocessBoxShadow); @@ -402,12 +403,14 @@ void registerDefaultProcessors(AttributesManager& attributesManager) { attributesManager.registerPreprocessor(borderRadiusAttributeId, &preprocessBorderRadius); attributesManager.registerPreprocessor(textGradientAttributeId, &preprocessGradient); attributesManager.registerPreprocessor(maskImageAttributeId, &preprocessGradient); + attributesManager.registerPreprocessor(fillGradientAttributeId, &preprocessGradient); attributesManager.registerPostprocessor(boxShadowAttributeId, &postprocessBoxShadow); attributesManager.registerPostprocessor(backgroundAttributeId, &postprocessGradient); attributesManager.registerPostprocessor(borderRadiusAttributeId, &postprocessBorderRadius); attributesManager.registerPostprocessor(textGradientAttributeId, &postprocessGradient); attributesManager.registerPostprocessor(maskImageAttributeId, &postprocessGradient); + attributesManager.registerPostprocessor(fillGradientAttributeId, &postprocessGradient); } } // namespace Valdi diff --git a/valdi/src/valdi/snap_drawing/Layers/Classes/ValdiShapeLayerClass.cpp b/valdi/src/valdi/snap_drawing/Layers/Classes/ValdiShapeLayerClass.cpp index 5948b3cf..9bc5c27b 100644 --- a/valdi/src/valdi/snap_drawing/Layers/Classes/ValdiShapeLayerClass.cpp +++ b/valdi/src/valdi/snap_drawing/Layers/Classes/ValdiShapeLayerClass.cpp @@ -9,8 +9,56 @@ #include "valdi/snap_drawing/Animations/ValdiAnimator.hpp" #include "valdi/snap_drawing/Utils/AttributesBinderUtils.hpp" +#include + namespace snap::drawing { +namespace { + +Valdi::Result applyFillGradientAttribute(ShapeLayer& shapeLayer, const Valdi::Value& value) { + const auto* array = value.getArray(); + if (array == nullptr || array->size() != 4) { + return Valdi::Error("Expecting 4 values from fillGradient"); + } + + const auto* colors = (*array)[0].getArray(); + const auto* locations = (*array)[1].getArray(); + auto orientation = static_cast((*array)[2].toInt()); + auto radial = (*array)[3].toBool(); + + if (colors == nullptr || locations == nullptr) { + return Valdi::Error("Expecting 2 arrays: colors and locations"); + } + + std::vector outColors; + outColors.reserve(colors->size()); + + for (const auto& color : *colors) { + outColors.emplace_back(snapDrawingColorFromValdiColor(color.toLong())); + } + + std::vector outLocations; + outLocations.reserve(locations->size()); + + for (const auto& location : *locations) { + outLocations.emplace_back(static_cast(location.toDouble())); + } + + if (radial) { + shapeLayer.setFillRadialGradient(std::move(outLocations), std::move(outColors)); + } else { + shapeLayer.setFillLinearGradient(std::move(outLocations), std::move(outColors), orientation); + } + + return Valdi::Void(); +} + +void resetFillGradientAttribute(ShapeLayer& shapeLayer) { + shapeLayer.resetFillGradient(); +} + +} // namespace + ValdiShapeLayerClass::ValdiShapeLayerClass(const Ref& resources, const Ref& parentClass) : ILayerClass(resources, "SCValdiShapeView", "com.snap.valdi.views.ShapeView", parentClass, false) {} ValdiShapeLayerClass::~ValdiShapeLayerClass() = default; @@ -25,6 +73,7 @@ void ValdiShapeLayerClass::bindAttributes(Valdi::AttributesBindingContext& binde BIND_DOUBLE_ATTRIBUTE(ValdiShapeLayer, strokeWidth, false); BIND_COLOR_ATTRIBUTE(ValdiShapeLayer, strokeColor, false); BIND_COLOR_ATTRIBUTE(ValdiShapeLayer, fillColor, false); + BIND_UNTYPED_ATTRIBUTE(ValdiShapeLayer, fillGradient, false); BIND_STRING_ATTRIBUTE(ValdiShapeLayer, strokeCap, false); BIND_STRING_ATTRIBUTE(ValdiShapeLayer, strokeJoin, false); BIND_DOUBLE_ATTRIBUTE(ValdiShapeLayer, strokeStart, false); @@ -97,6 +146,12 @@ IMPLEMENT_COLOR_ATTRIBUTE( Color::transparent()); }) +IMPLEMENT_UNTYPED_ATTRIBUTE( + ValdiShapeLayer, + fillGradient, + { return applyFillGradientAttribute(view, value); }, + { resetFillGradientAttribute(view); }) + IMPLEMENT_STRING_ATTRIBUTE( ValdiShapeLayer, strokeCap, diff --git a/valdi/src/valdi/snap_drawing/Layers/Classes/ValdiShapeLayerClass.hpp b/valdi/src/valdi/snap_drawing/Layers/Classes/ValdiShapeLayerClass.hpp index f6ebd61b..194c4ca2 100644 --- a/valdi/src/valdi/snap_drawing/Layers/Classes/ValdiShapeLayerClass.hpp +++ b/valdi/src/valdi/snap_drawing/Layers/Classes/ValdiShapeLayerClass.hpp @@ -26,6 +26,7 @@ class ValdiShapeLayerClass : public ILayerClass { DECLARE_DOUBLE_ATTRIBUTE(ValdiShapeLayer, strokeWidth) DECLARE_COLOR_ATTRIBUTE(ValdiShapeLayer, strokeColor) DECLARE_COLOR_ATTRIBUTE(ValdiShapeLayer, fillColor) + DECLARE_UNTYPED_ATTRIBUTE(ValdiShapeLayer, fillGradient) DECLARE_STRING_ATTRIBUTE(ValdiShapeLayer, strokeCap) DECLARE_STRING_ATTRIBUTE(ValdiShapeLayer, strokeJoin) DECLARE_DOUBLE_ATTRIBUTE(ValdiShapeLayer, strokeStart) diff --git a/valdi/test/ios/SCValdiShapeViewTests.m b/valdi/test/ios/SCValdiShapeViewTests.m new file mode 100644 index 00000000..658df22e --- /dev/null +++ b/valdi/test/ios/SCValdiShapeViewTests.m @@ -0,0 +1,170 @@ +#import + +#import "valdi/ios/Utils/GradientUtils.h" +#import "valdi/ios/Views/SCValdiShapeView.h" +#import "valdi_core/SCValdiAnimatorBase.h" + +@interface SCValdiShapeView (Tests) + +- (CAShapeLayer *)shapeLayer; +- (void)valdi_setPath:(id)pathData animator:(id)animator; +- (void)valdi_setStrokeStart:(CGFloat)strokeStart animator:(id)animator; +- (void)valdi_setStrokeEnd:(CGFloat)strokeEnd animator:(id)animator; +- (BOOL)valdi_setFillGradient:(NSArray *)attributeValue animator:(id)animator; +- (void)_resetFillGradientWithAnimator:(id)animator; +- (void)_setFillColor:(UIColor *)color animator:(id)animator; +- (void)_setStrokeColor:(UIColor *)color animator:(id)animator; +- (void)_setLineWidth:(CGFloat)lineWidth animator:(id)animator; + +@end + +@interface SCValdiShapeViewTests : XCTestCase +@end + +@implementation SCValdiShapeViewTests + +- (NSData *)rectangularPathData +{ + const double values[] = { + 100, 100, 1, + 1, 10, 10, + 2, 90, 10, + 2, 90, 90, + 2, 10, 90, + 7, + }; + return [NSData dataWithBytes:values length:sizeof(values)]; +} + +- (NSArray *)linearGradient +{ + return @[@[@0xFF0000FF, @0x00FF00FF], @[@0, @1], @(SCValdiLinearGradientOrientationLeftRight), @NO]; +} + +- (CAGradientLayer *)gradientLayerForView:(SCValdiShapeView *)view +{ + for (CALayer *layer in view.layer.sublayers) { + if ([layer isKindOfClass:CAGradientLayer.class]) { + return (CAGradientLayer *)layer; + } + } + return nil; +} + +- (CAShapeLayer *)strokeLayerForView:(SCValdiShapeView *)view +{ + for (CALayer *layer in view.layer.sublayers) { + if ([layer isKindOfClass:CAShapeLayer.class]) { + return (CAShapeLayer *)layer; + } + } + return nil; +} + +- (void)testLinearFillGradientUsesShapeMaskAndPreservesStroke +{ + SCValdiShapeView *view = [[SCValdiShapeView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + [view valdi_setPath:[self rectangularPathData] animator:nil]; + [view _setFillColor:UIColor.blueColor animator:nil]; + [view _setStrokeColor:UIColor.blackColor animator:nil]; + [view _setLineWidth:4 animator:nil]; + + XCTAssertTrue([view valdi_setFillGradient:[self linearGradient] animator:nil]); + + CAGradientLayer *gradientLayer = [self gradientLayerForView:view]; + CAShapeLayer *maskLayer = (CAShapeLayer *)gradientLayer.mask; + CAShapeLayer *strokeLayer = [self strokeLayerForView:view]; + + XCTAssertNotNil(gradientLayer); + XCTAssertEqualObjects(gradientLayer.type, kCAGradientLayerAxial); + XCTAssertEqualObjects(gradientLayer.locations, (@[@0, @1])); + XCTAssertTrue(CGPointEqualToPoint(gradientLayer.startPoint, CGPointMake(0, 0.5))); + XCTAssertTrue(CGPointEqualToPoint(gradientLayer.endPoint, CGPointMake(1, 0.5))); + XCTAssertTrue(CGRectEqualToRect(gradientLayer.frame, view.bounds)); + XCTAssertTrue(CGPathEqualToPath(maskLayer.path, view.shapeLayer.path)); + XCTAssertEqual(view.shapeLayer.fillColor, NULL); + XCTAssertEqual(view.shapeLayer.strokeColor, NULL); + XCTAssertTrue(CGColorEqualToColor(strokeLayer.strokeColor, UIColor.blackColor.CGColor)); + XCTAssertEqual(strokeLayer.lineWidth, 4); + XCTAssertGreaterThan([view.layer.sublayers indexOfObject:strokeLayer], + [view.layer.sublayers indexOfObject:gradientLayer]); +} + +- (void)testRadialFillGradientUsesRadialGradientLayer +{ + SCValdiShapeView *view = [[SCValdiShapeView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + NSArray *gradient = @[@[@0xFF0000FF, @0x0000FFFF], @[], @0, @YES]; + + XCTAssertTrue([view valdi_setFillGradient:gradient animator:nil]); + + CAGradientLayer *gradientLayer = [self gradientLayerForView:view]; + XCTAssertEqualObjects(gradientLayer.type, kCAGradientLayerRadial); + XCTAssertTrue(CGPointEqualToPoint(gradientLayer.startPoint, CGPointMake(0.5, 0.5))); + XCTAssertTrue(CGPointEqualToPoint(gradientLayer.endPoint, CGPointMake(1, 1))); +} + +- (void)testFillGradientTakesPrecedenceAndRestoresUpdatedFillColor +{ + SCValdiShapeView *view = [[SCValdiShapeView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + [view _setFillColor:UIColor.blueColor animator:nil]; + [view _setStrokeColor:UIColor.redColor animator:nil]; + [view valdi_setFillGradient:[self linearGradient] animator:nil]; + + [view _setFillColor:UIColor.yellowColor animator:nil]; + [view _setStrokeColor:UIColor.purpleColor animator:nil]; + + XCTAssertEqual(view.shapeLayer.fillColor, NULL); + XCTAssertEqual(view.shapeLayer.strokeColor, NULL); + XCTAssertNotNil([self gradientLayerForView:view]); + + [view _resetFillGradientWithAnimator:nil]; + + XCTAssertNil([self gradientLayerForView:view]); + XCTAssertNil([self strokeLayerForView:view]); + XCTAssertTrue(CGColorEqualToColor(view.shapeLayer.fillColor, UIColor.yellowColor.CGColor)); + XCTAssertTrue(CGColorEqualToColor(view.shapeLayer.strokeColor, UIColor.purpleColor.CGColor)); +} + +- (void)testSingleColorFillGradientFallsBackToSolidFill +{ + SCValdiShapeView *view = [[SCValdiShapeView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + [view valdi_setFillGradient:[self linearGradient] animator:nil]; + + NSArray *singleColorGradient = @[@[@0x0000FFFF], @[], @0, @NO]; + XCTAssertTrue([view valdi_setFillGradient:singleColorGradient animator:nil]); + + XCTAssertNil([self gradientLayerForView:view]); + XCTAssertTrue(CGColorEqualToColor(view.shapeLayer.fillColor, UIColor.blueColor.CGColor)); +} + +- (void)testFillGradientUpdatesWithPathStrokeAndBounds +{ + SCValdiShapeView *view = [[SCValdiShapeView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + [view valdi_setFillGradient:[self linearGradient] animator:nil]; + [view valdi_setPath:[self rectangularPathData] animator:nil]; + [view _setStrokeColor:UIColor.purpleColor animator:nil]; + [view _setLineWidth:6 animator:nil]; + [view valdi_setStrokeStart:0.25 animator:nil]; + [view valdi_setStrokeEnd:0.75 animator:nil]; + + CAGradientLayer *gradientLayer = [self gradientLayerForView:view]; + CAShapeLayer *maskLayer = (CAShapeLayer *)gradientLayer.mask; + CAShapeLayer *strokeLayer = [self strokeLayerForView:view]; + + XCTAssertTrue(CGPathEqualToPath(maskLayer.path, view.shapeLayer.path)); + XCTAssertTrue(CGPathEqualToPath(strokeLayer.path, view.shapeLayer.path)); + XCTAssertTrue(CGColorEqualToColor(strokeLayer.strokeColor, UIColor.purpleColor.CGColor)); + XCTAssertEqual(strokeLayer.lineWidth, 6); + XCTAssertEqualWithAccuracy(strokeLayer.strokeStart, 0.25, 0.001); + XCTAssertEqualWithAccuracy(strokeLayer.strokeEnd, 0.75, 0.001); + + view.frame = CGRectMake(0, 0, 160, 120); + [view setNeedsLayout]; + [view layoutIfNeeded]; + + XCTAssertTrue(CGRectEqualToRect(gradientLayer.frame, view.bounds)); + XCTAssertTrue(CGRectEqualToRect(maskLayer.frame, view.bounds)); + XCTAssertTrue(CGRectEqualToRect(strokeLayer.frame, view.bounds)); +} + +@end diff --git a/valdi/test/runtime/AttributeProcessors_tests.cpp b/valdi/test/runtime/AttributeProcessors_tests.cpp index 929b763e..2f539859 100644 --- a/valdi/test/runtime/AttributeProcessors_tests.cpp +++ b/valdi/test/runtime/AttributeProcessors_tests.cpp @@ -103,6 +103,30 @@ TEST(AttributeProcessor, canParseLinearGradientWithLocations) { ASSERT_EQ(result.value(), rtlResult.value()); } +TEST(AttributeProcessor, canParseLinearGradientWithTransparentRgbaStop) { + auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("linear-gradient(red 0, rgba(0,0,0,0) 1)"))); + + ASSERT_TRUE(result.success()) << result.description(); + + ASSERT_EQ(makeGradientValue( + { + Color::rgba(255, 0, 0, 1.0), + Color::rgba(0, 0, 0, 0.0), + }, + { + 0.0, + 1.0, + }, + 0, + false), + result.value()); + + auto postprocessed = postprocessGradient(false, result.value()); + ASSERT_TRUE(postprocessed) << postprocessed.description(); + ASSERT_EQ(makeGradientValue({Color::rgba(255, 0, 0, 1.0), Color::rgba(0, 0, 0, 0.0)}, {0.0, 1.0}, 0, false), + postprocessed.value()); +} + TEST(AttributeProcessor, failsWhenLinearGradientWithLocationsIsNotBalanced) { auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("linear-gradient(blue 0, white 0.25, red)")));