Skip to content

Commit

Permalink
Merge pull request #54 from skiptools/redacted
Browse files Browse the repository at this point in the history
Support .redacted(reason: .placeholder)
  • Loading branch information
aabewhite authored Sep 13, 2024
2 parents 5100ebb + 90deb26 commit b466250
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 44 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,17 @@ Support levels:
</details>
</td>
</tr>
<tr>
<td>🟡</td>
<td>
<details>
<summary><code>.redacted</code></summary>
<ul>
<li>Only <code>RedactionReasons.placeholder</code> is supported</li>
</ul>
</details>
</td>
</tr>
<tr>
<td>✅</td>
<td><code>.refreshable</code></td>
Expand Down
55 changes: 55 additions & 0 deletions Sources/SkipUI/Skip/Redacted.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
10 changes: 6 additions & 4 deletions Sources/SkipUI/SkipUI/Color/Color.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 37 additions & 20 deletions Sources/SkipUI/SkipUI/Components/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
})
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
12 changes: 0 additions & 12 deletions Sources/SkipUI/SkipUI/Text/Font.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 53 additions & 8 deletions Sources/SkipUI/SkipUI/Text/Text.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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()
Expand All @@ -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<TextLayoutResult?>(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() {
Expand Down Expand Up @@ -361,16 +383,16 @@ struct _Text: View, Equatable {
}
}

private func annotatedString(markdown: MarkdownNode, interpolations: kotlin.collections.List<AnyHashable>?, linkColor: androidx.compose.ui.graphics.Color, isUppercased: Bool, isLowercased: Bool) -> AnnotatedString {
private func annotatedString(markdown: MarkdownNode, interpolations: kotlin.collections.List<AnyHashable>?, 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<AnyHashable>?, 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<AnyHashable>?, 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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit b466250

Please sign in to comment.