Created
March 9, 2025 09:24
-
-
Save Koshimizu-Takehito/cc3d48d0177181f75c40b3779af37a7c to your computer and use it in GitHub Desktop.
表示中のピクセルデータからボタンの色を切り替える
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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