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
+
+ - Only
RedactionReasons.placeholder is supported
+
+
+ |
+
✅ |
.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