📸

【iOS/Android】UI要素のキャプチャの撮り方いろいろ

2024/10/31に公開

SwiftUI、UIKit、Jetpack Compose、Android Viewにて、とあるUI要素をキャプチャしたい場合にどのように実装すればいいかを書き残しておきます。

※この記事は技術書典17でWAKE Community本第15章に載せた内容になります。ぜひこちらもよろしくお願いします!
https://techbookfest.org/product/1Y5sf1TGi79cu7T7uBW74t

実行環境

Xcode: 16.0
Swift: 5
Android Studio: Koala Feature Drop | 2024.1.2
Kotlin: 1.9.0

iOSの場合

こんなかんじのUIで作りました。「Save profile image」ボタンをタップすると、私のプロフィール画像(UIImageView/Imageに設定したimage)が端末に保存されます。

端末にはこのように保存されています。

※以下のコードをコピペしただけだと、写真書き込み権限の関係で動かない、もしくはクラッシュします。手元で実行する場合はInfo.plistで以下のkeyを設定し、適切なvalueを追加してください。

  • Privacy - Photo Library Usage Description
  • Privacy - Photo Library Additions Usage Description

SwiftUI

struct ContentView: View {
    @Environment(\.displayScale) private var displayScale

    var body: some View {
        let profileImage = Image("profile_image")
            .resizable()
            .scaledToFit()
            .frame(width: 100, height: 100)

        VStack {
            profileImage
            Text("Hello, world!")
            Button(action: {
                saveImageToPhotos(makeImage(body: profileImage))
            }) {
                Text("Save profile image")
            }
            .padding(.top, 8)
        }
        .padding()
    }

    @MainActor
    private func makeImage(body: some View) -> UIImage? {
        // iOS 16以上であればこんなに簡単にレンダリングできる
        let renderer = ImageRenderer(content: body)
        // デバイスのディスプレイ解像度に最適な画像を生成
        renderer.scale = displayScale
        return renderer.uiImage
    }

    // 正しくスクショが撮れているかを確認するコードなので、
    // 今回の検証の本質部分ではない
    private func saveImageToPhotos(_ image: UIImage?) {
        guard let image else { return }
        UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
    }
}

UIKit

UIはStoryboardで作ってますし、SwiftUIの時と変わらないので省略します。処理もSwiftUIとほぼ同等です。

class ViewController: UIViewController {

    @IBOutlet weak var imageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    @IBAction func saveImageToPhotos(_ sender: Any) {
        let screenshot = makeImage(of: imageView)
        UIImageWriteToSavedPhotosAlbum(screenshot, nil, nil, nil)
    }

    private func makeImage(of imageView: UIImageView) -> UIImage {
        let renderer = UIGraphicsImageRenderer(size: imageView.bounds.size)
        return renderer.image { ctx in
            imageView.layer.render(in: ctx.cgContext)
        }
    }
}

Androidの場合

iOSと似たUIで作りました。「Save profile image」ボタンをタップすると、私のプロフィール画像(ImageView/Imageに設定したimage)が端末に保存されます。

端末にはこのように保存されています。

Jetpack Compose

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ScreenshotSmapleTheme {
                MainScreen()
            }
        }
    }
}

@Composable
fun MainScreen() {
    val context = LocalContext.current
    val view = LocalView.current

    // キャプチャする領域の座標を保持する
    val captureArea = remember { mutableStateOf<Rect?>(null) }

    Box(modifier = Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Image(
                painter = painterResource(id = R.drawable.profile_image),
                contentDescription = null,
                modifier = Modifier
                    .onGloballyPositioned { layoutCoordinates ->
                        val position = layoutCoordinates.positionInWindow()
                        val size = layoutCoordinates.size.toSize()
                        captureArea.value = Rect(
                            position.x.toInt(),
                            position.y.toInt(),
                            (position.x + size.width).toInt(),
                            (position.y + size.height).toInt()
                        )
                    }
            )
            Spacer(modifier = Modifier.height(16.dp))
            Text(
                text = "Hello, world!"
            )
            Spacer(modifier = Modifier.height(16.dp))
            Button(
                onClick = {
                    captureArea.value?.let { rect ->
                        // View全体のBitmapをキャプチャ
                        val bitmap = view.drawToBitmap()
                        // 特定の部分だけを切り出す
                        val croppedBitmap = Bitmap.createBitmap(
                            bitmap,
                            rect.left,
                            rect.top,
                            rect.width(),
                            rect.height()
                        )
                        saveImage(context = context, bitmap = croppedBitmap)
                    }
                }
            ) {
                Text(
                    text = "Save profile image"
                )
            }
        }
    }
}

// 正しくスクショが撮れているかを確認するコードなので、
// 今回の検証の本質部分ではない(のでちょっとおかしくても許して)
private fun saveImage(context: Context, bitmap: Bitmap) {
    val filename = UUID.randomUUID().toString() + ".jpg"
    val contentValues = ContentValues().apply {
        put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
        put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
        put(MediaStore.MediaColumns.RELATIVE_PATH,
            Environment.DIRECTORY_PICTURES + "/Screenshots")
    }
    val uri = contentResolver.insert(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues
    )
    uri?.let {
        context.contentResolver.openOutputStream(it).use { outputStream ->
            outputStream ?: return@let
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
        }
    }
}

Android View

UIは省略します。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val imageView = findViewById<ImageView>(R.id.imageView)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
            val bitmap = makeBitmap(imageView)
            saveImage(bitmap)
        }
    }

    private fun makeBitmap(view: View): Bitmap {
        // ImageViewをBitmapに変換
        val bitmap = Bitmap.createBitmap(
            view.width, view.height, Bitmap.Config.ARGB_8888
        )
        val canvas = Canvas(bitmap)
        view.draw(canvas)
        return bitmap
    }

    // 正しくスクショが撮れているかを確認するコードなので、
    // 今回の検証の本質部分ではない(のでちょっとおかしくても許して)
    private fun saveImage(bitmap: Bitmap) {
        val filename = UUID.randomUUID().toString() + ".jpg"
        val contentValues = ContentValues().apply {
            put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
            put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
            put(MediaStore.MediaColumns.RELATIVE_PATH,
                Environment.DIRECTORY_PICTURES + "/Screenshots")
        }
        val uri = contentResolver.insert(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues
        )
        uri?.let {
            contentResolver.openOutputStream(it).use { outputStream ->
                outputStream ?: return@let
                bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
            }
        }
    }
}

Discussion