Skip to content

Instantly share code, notes, and snippets.

@Koshimizu-Takehito
Created March 9, 2025 09:24
Show Gist options
  • Save Koshimizu-Takehito/cc3d48d0177181f75c40b3779af37a7c to your computer and use it in GitHub Desktop.
Save Koshimizu-Takehito/cc3d48d0177181f75c40b3779af37a7c to your computer and use it in GitHub Desktop.
表示中のピクセルデータからボタンの色を切り替える
import SwiftUI
struct ContentView: View {
@State private var image = UIImage()
@State private var buttonRect = CGRect()
@State private var isBrightBackground = false
@State private var viewID = UUID()
var body: some View {
NavigationStack {
GeometryReader { geometry in
VStack {
MyScrollView(image: $image)
Color.clear
.frame(width: 100, height: 200)
.overlay {
Image(uiImage: image)
.resizable()
.scaledToFit()
}
}
.ignoresSafeArea()
.toolbarBackgroundVisibility(.hidden, for: .navigationBar)
.toolbar(content: button)
.onChange(of: image, initial: true) { _, image in
let (isBright, bounds) = (isBrightBackground, buttonRect)
Task.detached {
let isBright = image.isBrightArea(in: bounds) ?? isBright
Task { @MainActor in
self.isBrightBackground = isBright
}
}
}
}
}
.id(viewID)
}
func button() -> some View {
MyButton(isBrightBackground: isBrightBackground) {
withAnimation { viewID = UUID() }
}
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { buttonRect in
self.buttonRect = buttonRect
}
}
}
struct MyButton: View {
var isBrightBackground: Bool
var action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.frame(width: 44)
.foregroundStyle(.thinMaterial)
.overlay {
Image(systemName: "xmark")
.resizable()
.scaledToFit()
.foregroundStyle(.white)
.frame(width: 22.0 / sqrt(2))
}
}
.environment(\.colorScheme, isBrightBackground ? .dark : .light)
}
}
struct ItemView: View, Hashable {
var body: some View {
Color(
hue: random,
saturation: Bool.random() ? random * random : random,
brightness: 1 - random * random
)
}
var random: Double {
Double((0..<0xFF).randomElement()!) / Double(0xFF)
}
}
struct MyScrollView: View {
@State private var offset: CGPoint = .zero
@Binding var image: UIImage
var body: some View {
ScrollView(.vertical) {
VStack(spacing: 0) {
ForEach(0..<300, id: \.self) { index in
ItemView()
.frame(height: 120)
.id(index)
}
}
}
.onScrollGeometryChange(for: CGPoint.self, of: \.contentOffset) {
self.offset = $1
}
.background {
UIScrollViewImageRenderer(offset: offset, image: $image)
}
}
}
struct UIScrollViewImageRenderer: UIViewControllerRepresentable {
var offset: CGPoint = .zero
@Binding var image: UIImage
func makeUIViewController(context: Context) -> AnonymousViewController {
AnonymousViewController()
}
func updateUIViewController(_ controller: AnonymousViewController, context: Context) {
controller.coordinator = context.coordinator
controller.offset = offset
}
func makeCoordinator() -> Coordinator {
Coordinator(image: $image)
}
final class Coordinator: AnyObject {
@Binding var image: UIImage
init(image: Binding<UIImage>) {
_image = image
}
}
final class AnonymousViewController: UIViewController {
weak var coordinator: Coordinator?
var offset: CGPoint = .zero {
didSet {
if isViewLoaded, oldValue != offset {
drawScrollView()
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
drawScrollView()
}
func drawScrollView() {
if let window = view.window, let scrollView: UIScrollView = find(ancestor: window) {
let bounds = view.bounds
let offset = scrollView.contentOffset
let layer = scrollView.layer
Task.detached {
let renderer = UIGraphicsImageRenderer(size: bounds.size)
var image = renderer.image { context in
context.cgContext.translateBy(x: -offset.x, y: -offset.y)
layer.render(in: context.cgContext)
}
image = image.preparingForDisplay() ?? image
Task { @MainActor in
self.coordinator?.image = image
}
}
}
}
func find<View: UIView>(ancestor: UIView?) -> View? {
switch ancestor {
case nil:
return nil
case let target as View:
return target
case let target?:
return target.subviews.lazy.map(find).compactMap(\.self).first
}
}
}
}
extension UIImage {
/// 指定した矩形領域のピクセルを読み込み、平均輝度を求める
/// - Parameter cropRect: 切り抜く領域(画像内座標)
/// - Returns: ピクセルの平均輝度(0.0〜255.0)
func averageLuminance(in cropRect: CGRect) -> Double? {
// 画像の CGImage を取り出す
guard let cgImage = self.cgImage else { return nil }
// 座標系・サイズが正しく指定されているかを確認しつつクロップ
let scale = self.scale
let adjustedRect = CGRect(x: cropRect.origin.x * scale,
y: cropRect.origin.y * scale,
width: cropRect.size.width * scale,
height: cropRect.size.height * scale)
guard let croppedCGImage = cgImage.cropping(to: adjustedRect) else {
return nil
}
let width = Int(adjustedRect.width)
let height = Int(adjustedRect.height)
// RGBA(4バイト)でピクセルデータを格納するバッファを用意
let bytesPerPixel = 4
let bitsPerComponent = 8
let bytesPerRow = bytesPerPixel * width
guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { return nil }
guard let rawData = calloc(width * height, MemoryLayout<UInt8>.size * bytesPerPixel) else {
return nil
}
// CGContext を生成し、クロップした CGImage を (0,0) に描画
guard let context = CGContext(data: rawData,
width: width,
height: height,
bitsPerComponent: bitsPerComponent,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
else {
free(rawData)
return nil
}
let drawRect = CGRect(x: 0, y: 0, width: width, height: height)
context.draw(croppedCGImage, in: drawRect)
// 輝度を合計して最後に平均をとる
var totalLuminance: Double = 0.0
// UnsafeRawPointer を UInt8 の配列のように扱う
let pixelBuffer = rawData.bindMemory(to: UInt8.self, capacity: width * height * 4)
for y in 0..<height {
for x in 0..<width {
let offset = (y * width + x) * 4
let r = Double(pixelBuffer[offset + 0])
let g = Double(pixelBuffer[offset + 1])
let b = Double(pixelBuffer[offset + 2])
// 輝度の近似式
let luminance = 0.299 * r + 0.587 * g + 0.114 * b
totalLuminance += luminance
}
}
free(rawData)
let count = Double(width * height)
let averageLuminance = totalLuminance / count
return averageLuminance
}
/// 指定した矩形領域が「明るい」と判定できるか
/// - Parameters:
/// - cropRect: 切り抜く領域
/// - threshold: 平均輝度がこの値を超えたら「明るい」とみなす。0〜255の範囲で設定。
/// - Returns: 平均輝度が閾値を超えるかどうか
func isBrightArea(in cropRect: CGRect, threshold: Double = 128.0) -> Bool? {
guard let avgLum = averageLuminance(in: cropRect) else {
return nil
}
return avgLum > threshold
}
}
#Preview {
ContentView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment