diff --git a/README.md b/README.md index e6f0be48..17948cff 100644 --- a/README.md +++ b/README.md @@ -1353,6 +1353,17 @@ Support levels: + + 🟡 + +
+ .redacted + +
+ + ✅ .refreshable diff --git a/Sources/SkipUI/Skip/Redacted.kt b/Sources/SkipUI/Skip/Redacted.kt new file mode 100644 index 00000000..7ee35d07 --- /dev/null +++ b/Sources/SkipUI/Skip/Redacted.kt @@ -0,0 +1,55 @@ +// Copyright 2024 Skip +// +// This is free software: you can redistribute and/or modify it +// under the terms of the GNU Lesser General Public License 3.0 +// as published by the Free Software Foundation https://fsf.org + +package skip.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import kotlin.math.ceil + +/// Compose the given content with opaque redaction treatment. +@Composable fun Redacted(context: ComposeContext, color: Color, content: @Composable (ComposeContext) -> Unit) { + val contentContext = context.content() + val redactedContext = context.content(modifier = Modifier + .drawWithContent { + val matrix = redactedColorMatrix(color) + val filter = ColorFilter.colorMatrix(matrix) + val paint = Paint().apply { + colorFilter = filter + } + drawIntoCanvas { canvas -> + canvas.saveLayer(Rect(0f, 0f, size.width, size.height), paint) + drawContent() + canvas.restore() + } + } + ) + content(redactedContext) +} + +private fun redactedColorMatrix(color: Color): ColorMatrix { + return ColorMatrix().apply { + set(0, 0, 0f) // Do not preserve original R + set(1, 1, 0f) // Do not preserve original G + set(2, 2, 0f) // Do not preserve original B + + set(0, 4, color.red * 255) // Use given color's R + set(1, 4, color.green * 255) // Use given color's G + set(2, 4, color.blue * 255) // Use given color's B + set(3, 3, color.alpha) // Multiply original alpha by shadow color alpha + } +} diff --git a/Sources/SkipUI/SkipUI/Color/Color.swift b/Sources/SkipUI/SkipUI/Color/Color.swift index 477586bb..938044de 100644 --- a/Sources/SkipUI/SkipUI/Color/Color.swift +++ b/Sources/SkipUI/SkipUI/Color/Color.swift @@ -174,10 +174,12 @@ public struct Color: ShapeStyle, Hashable, Sendable { }) /// Use for placeholder content. - static let placeholder = Color(colorImpl: { - // Close to iOS's AsyncImage placeholder values - ComposeColor(light: 0xFFDDDDDD, dark: 0xFF777777) - }) + static let placeholderOpacity = 0.2 + + /// Use for placeholder content. + static var placeholder: Color { + _primary.opacity(placeholderOpacity) + } fileprivate static let _primary = Color(colorImpl: { MaterialTheme.colorScheme.onBackground diff --git a/Sources/SkipUI/SkipUI/Components/Image.swift b/Sources/SkipUI/SkipUI/Components/Image.swift index 24d5a104..b1e70f82 100644 --- a/Sources/SkipUI/SkipUI/Components/Image.swift +++ b/Sources/SkipUI/SkipUI/Components/Image.swift @@ -21,10 +21,12 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor @@ -34,6 +36,7 @@ import androidx.compose.ui.graphics.asAndroidPath import androidx.compose.ui.graphics.asComposePath import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder @@ -137,19 +140,11 @@ public struct Image : View, Equatable { .diskCacheKey(url.description) .build() - - let tintColor = EnvironmentValues.shared._foregroundStyle?.asColor(opacity: 1.0, animationContext: context) ?? Color.primary.colorImpl() - let colorFilter: ColorFilter? - if let tintColor, asset.isTemplateImage == true { - colorFilter = ColorFilter.tint(tintColor) - } else { - colorFilter = nil - } - + let tintColor = asset.isTemplateImage ? EnvironmentValues.shared._foregroundStyle?.asColor(opacity: 1.0, animationContext: context) ?? Color.primary.colorImpl() : nil SubcomposeAsyncImage(model: model, contentDescription: nil, loading: { _ in }, success: { state in let aspect = EnvironmentValues.shared._aspectRatio - ComposePainter(painter: self.painter, colorFilter: colorFilter, scale: scale, aspectRatio: aspect?.0, contentMode: aspect?.1) + ComposePainter(painter: self.painter, tintColor: tintColor, scale: scale, aspectRatio: aspect?.0, contentMode: aspect?.1) }, error: { state in }) } @@ -313,7 +308,16 @@ public struct Image : View, Equatable { ComposePainter(painter: painter, scale: scale, aspectRatio: aspectRatio, contentMode: contentMode) } - @Composable private func ComposePainter(painter: Painter, scale: CGFloat = 1.0, colorFilter: ColorFilter? = nil, aspectRatio: Double?, contentMode: ContentMode?) { + @Composable private func ComposePainter(painter: Painter, scale: CGFloat = 1.0, tintColor: androidx.compose.ui.graphics.Color? = nil, aspectRatio: Double?, contentMode: ContentMode?) { + let isPlaceholder = EnvironmentValues.shared.redactionReasons.contains(.placeholder) + let colorFilter: ColorFilter? + if let tintColor { + colorFilter = isPlaceholder ? placeholderColorFilter(color: tintColor.copy(alpha: Float(Color.placeholderOpacity))) : ColorFilter.tint(tintColor) + } else if isPlaceholder { + colorFilter = placeholderColorFilter(color: Color.placeholder.colorImpl()) + } else { + colorFilter = nil + } switch resizingMode { case .stretch: let scale = contentScale(aspectRatio: aspectRatio, contentMode: contentMode) @@ -349,16 +353,10 @@ public struct Image : View, Equatable { switch resizingMode { case .stretch: let painter = rememberVectorPainter(image) - let colorFilter: ColorFilter? - if let tintColor { - colorFilter = ColorFilter.tint(tintColor) - } else { - colorFilter = nil - } - ComposePainter(painter: painter, colorFilter: colorFilter, aspectRatio: aspectRatio, contentMode: contentMode) + ComposePainter(painter: painter, tintColor: tintColor, aspectRatio: aspectRatio, contentMode: contentMode) default: // TODO: .tile let textStyle = EnvironmentValues.shared.font?.fontImpl() ?? LocalTextStyle.current - let modifier: Modifier + var modifier: Modifier if textStyle.fontSize.isSp { let textSizeDp = with(LocalDensity.current) { textStyle.fontSize.toDp() @@ -368,8 +366,27 @@ public struct Image : View, Equatable { } else { modifier = Modifier } - Icon(imageVector: image, contentDescription: name, modifier: modifier, tint: tintColor ?? androidx.compose.ui.graphics.Color.Unspecified) + let isPlaceholder = EnvironmentValues.shared.redactionReasons.contains(RedactionReasons.placeholder) + if isPlaceholder { + modifier = modifier.paint(ColorPainter(tintColor.copy(alpha: Float(Color.placeholderOpacity)))) + } + Icon(imageVector: image, contentDescription: name, modifier: modifier, tint: isPlaceholder ? androidx.compose.ui.graphics.Color.Transparent : tintColor) + } + } + + private func placeholderColorFilter(color: androidx.compose.ui.graphics.Color) -> ColorFilter { + let matrix = ColorMatrix().apply { + set(0, 0, Float(0)) // Do not preserve original R + set(1, 1, Float(0)) // Do not preserve original G + set(2, 2, Float(0)) // Do not preserve original B + set(3, 3, Float(0)) // Do not preserve original A + + set(0, 4, color.red * 255) // Use given color's R + set(1, 4, color.green * 255) // Use given color's G + set(2, 4, color.blue * 255) // Use given color's B + set(3, 4, color.alpha * 255) // Use given color's A } + return ColorFilter.colorMatrix(matrix) } private func contentScale(aspectRatio: Double?, contentMode: ContentMode?) -> ContentScale { diff --git a/Sources/SkipUI/SkipUI/Text/Font.swift b/Sources/SkipUI/SkipUI/Text/Font.swift index edf7f03c..74950d88 100644 --- a/Sources/SkipUI/SkipUI/Text/Font.swift +++ b/Sources/SkipUI/SkipUI/Text/Font.swift @@ -386,18 +386,6 @@ public enum LegibilityWeight : Hashable, Sendable { case bold } -public struct RedactionReasons : OptionSet, Sendable { - public let rawValue: Int - - public init(rawValue: Int) { - self.rawValue = rawValue - } - - public static let placeholder = RedactionReasons(rawValue: 1 << 0) - public static let privacy = RedactionReasons(rawValue: 1 << 1) - public static let invalidated = RedactionReasons(rawValue: 1 << 2) -} - #if !SKIP // Unneeded stubs: diff --git a/Sources/SkipUI/SkipUI/Text/Text.swift b/Sources/SkipUI/SkipUI/Text/Text.swift index a7827a05..18aff491 100644 --- a/Sources/SkipUI/SkipUI/Text/Text.swift +++ b/Sources/SkipUI/SkipUI/Text/Text.swift @@ -8,10 +8,17 @@ import Foundation #if SKIP import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.DrawModifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.Brush import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.AnnotatedString @@ -300,12 +307,15 @@ struct _Text: View, Equatable { font = font.italic() } let textDecoration = textEnvironment.textDecoration + let redaction = EnvironmentValues.shared.redactionReasons var textColor: androidx.compose.ui.graphics.Color? = nil var textBrush: Brush? = nil if let foregroundStyle = EnvironmentValues.shared._foregroundStyle { if let color = foregroundStyle.asColor(opacity: 1.0, animationContext: context) { textColor = color + } else if !redaction.isEmpty { + textColor = Color.primary.colorImpl() } else { textBrush = foregroundStyle.asBrush(opacity: 1.0, animationContext: context) } @@ -316,6 +326,7 @@ struct _Text: View, Equatable { } else { textColor = EnvironmentValues.shared._placement.contains(ViewPlacement.systemTextColor) ? androidx.compose.ui.graphics.Color.Unspecified : Color.primary.colorImpl() } + let textAlign = EnvironmentValues.shared.multilineTextAlignment.asTextAlign() let maxLines = max(1, EnvironmentValues.shared.lineLimit ?? Int.MAX_VALUE) var style = font.fontImpl() @@ -325,11 +336,22 @@ struct _Text: View, Equatable { if let textBrush { style = style.copy(brush: textBrush) } + if redaction.contains(RedactionReasons.placeholder) { + if let textColor { + style = style.copy(background: textColor.copy(alpha: textColor.alpha * Float(Color.placeholderOpacity))) + } + textColor = androidx.compose.ui.graphics.Color.Transparent + } + let animatable = style.asAnimatable(context: context) if let locnode { let layoutResult = remember { mutableStateOf(nil) } - let linkColor = EnvironmentValues.shared._tint?.colorImpl() ?? Color.accentColor.colorImpl() - let annotatedText = annotatedString(markdown: locnode, interpolations: interpolations, linkColor: linkColor, isUppercased: isUppercased, isLowercased: isLowercased) + let isPlaceholder = redaction.contains(RedactionReasons.placeholder) + var linkColor = EnvironmentValues.shared._tint?.colorImpl() ?? Color.accentColor.colorImpl() + if isPlaceholder { + linkColor = linkColor.copy(alpha: linkColor.alpha * Float(Color.placeholderOpacity)) + } + let annotatedText = annotatedString(markdown: locnode, interpolations: interpolations, linkColor: linkColor, isUppercased: isUppercased, isLowercased: isLowercased, isRedacted: isPlaceholder) let links = annotatedText.getUrlAnnotations(start: 0, end: annotatedText.length) var modifier = context.modifier if !links.isEmpty() { @@ -361,16 +383,16 @@ struct _Text: View, Equatable { } } - private func annotatedString(markdown: MarkdownNode, interpolations: kotlin.collections.List?, linkColor: androidx.compose.ui.graphics.Color, isUppercased: Bool, isLowercased: Bool) -> AnnotatedString { + private func annotatedString(markdown: MarkdownNode, interpolations: kotlin.collections.List?, linkColor: androidx.compose.ui.graphics.Color, isUppercased: Bool, isLowercased: Bool, isRedacted: Bool) -> AnnotatedString { return buildAnnotatedString { - append(markdown: markdown, to: self, interpolations: interpolations, linkColor: linkColor, isUppercased: isUppercased, isLowercased: isLowercased) + append(markdown: markdown, to: self, interpolations: interpolations, linkColor: linkColor, isUppercased: isUppercased, isLowercased: isLowercased, isRedacted: isRedacted) } } // SKIP INSERT: @OptIn(ExperimentalTextApi::class) - private func append(markdown: MarkdownNode, to builder: AnnotatedString.Builder, interpolations: kotlin.collections.List?, isFirstChild: Bool = true, linkColor: androidx.compose.ui.graphics.Color, isUppercased: Bool, isLowercased: Bool) { + private func append(markdown: MarkdownNode, to builder: AnnotatedString.Builder, interpolations: kotlin.collections.List?, isFirstChild: Bool = true, linkColor: androidx.compose.ui.graphics.Color, isUppercased: Bool, isLowercased: Bool, isRedacted: Bool) { func appendChildren() { - markdown.children?.forEachIndexed { append(markdown: $1, to: builder, interpolations: interpolations, isFirstChild: $0 == 0, linkColor: linkColor, isUppercased: isUppercased, isLowercased: isLowercased) } + markdown.children?.forEachIndexed { append(markdown: $1, to: builder, interpolations: interpolations, isFirstChild: $0 == 0, linkColor: linkColor, isUppercased: isUppercased, isLowercased: isLowercased, isRedacted: isRedacted) } } switch markdown.type { @@ -395,7 +417,11 @@ struct _Text: View, Equatable { appendChildren() builder.pop() case MarkdownNode.NodeType.link: - builder.pushStyle(SpanStyle(color: linkColor)) + if isRedacted { + builder.pushStyle(SpanStyle(background: linkColor)) + } else { + builder.pushStyle(SpanStyle(color: linkColor)) + } builder.pushUrlAnnotation(UrlAnnotation(markdown.formattedString(interpolations) ?? "")) appendChildren() builder.pop() @@ -606,9 +632,12 @@ extension View { return self } - @available(*, unavailable) public func redacted(reason: RedactionReasons) -> some View { + #if SKIP + return environment(\.redactionReasons, reason) + #else return self + #endif } @available(*, unavailable) @@ -681,6 +710,22 @@ extension View { } } +public struct RedactionReasons : OptionSet, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let placeholder = RedactionReasons(rawValue: 1 << 0) + + @available(*, unavailable) + public static let privacy = RedactionReasons(rawValue: 1 << 1) + + @available(*, unavailable) + public static let invalidated = RedactionReasons(rawValue: 1 << 2) +} + #if false // TODO: Process for use in SkipUI