大きなサイズの画像を画面にフィットして表示する

画像の縦横のピクセル数が、画面の縦横のポイント数より大きい場合はフィットして画像を表示することで全体像を表示するようにします。

iPhone14 だとセーフエリアを除いた部分の描画サイズで ZStack が占める領域は、390.0 × 763.0 ポイントになります。下記の Fig. 1 〜 Fig. 6 のシミュレータ実行例では上から2行目の GeomWH というところに示されている数値がそれにあたります。

ここに画像データピクセルと、画面ポイントを 1:1 の関係としてむりやり描画するとしても 390 × 763 ピクセルよりも大きいデータになると必ず縮小表示の考え方を導入しなくてはなりません。

また、ユーザが画面をタップしたときに取得できる座標はあくまでも画面上のタップ座標であって単位はポイントです、ピクセルではありません。画像表示の周辺部ではマイナスの値のタップ座標も取得されてしまうので、ポイントからピクセルに単純換算するだけでは画像処理などする場合、アドレスエラーになることは必至です。

ですので、タップしたポイント座標は画像の内側か、外側か、もし外側ならば必ず画面の内側のピクセル座標に収めなくてはなりません。

たとえば 640 × 480 ピクセルの画像では、ピクセル座標として取りうる範囲は、左上隅ピクセル座標が ( 0, 0 ) で、右下隅ピクセル座標が ( 639, 479 ) でなくてはなりません。

一般的に記述すると W × H ピクセルの画像の場合は、左上隅 ( 0, 0 ) で、右下隅が ( W - 1, H - 1 ) です。

前置きが長くなってしまいましたが、実際のコードを実行して動作をご確認ください。

シミュレータ画面上の表示について

・ScreenWH がセーフエリアも含んだ画面全体のサイズ (ポイント単位)
・ScreenWH がセーフエリアも含んだ画面全体のサイズ (ポイント単位)
・GeomWH がセーフエリアを含まない ZStack が占めるサイズ (ポイント単位)
・TapXY が画像をタップしたときの座標 (ポイント単位)
・InsideRectTapXY が画像の内側になるように範囲限定した座標 (ポイント単位)
・DataWH が画像データのサイズ (ピクセル単位)
・ThePixelXY が画像の内側になるように範囲限定した座標 (ピクセル単位)

のようになっています。実行時の参考にしてください。

Fig. 5 と Fig. 6 については、画面サイズのポイント数に対して画像サイズのピクセル数が小さいので、拡大表示ということになります。

Fig. 1 画像サイズ 256 * 1024 ピクセルの画面内側をタップしたとき
Fig. 2 画像サイズ 256 * 1024 ピクセルの画面外側の右下をタップしたとき
Fig. 3 画像サイズ 512 * 256 ピクセルの画面内側をタップしたとき
Fig. 4 画像サイズ 512 * 256 ピクセルの画面外側の右下をタップしたとき
Fig. 5 画像サイズ 256 * 256 ピクセルの画面内側をタップしたとき
Fig. 6 画像サイズ 256 * 256 ピクセルの画面外側の右下をタップしたとき
Fig. 7 ワンブロックの目安になる 256*256ピクセルの画像データ

コードの解説をします。

26 〜 36行目で画像データのサイズを決定しています。いろいろなサイズを試してみてください。
147 〜 197行目で指定した画像データの領域を確保してデバッグ用のデータを格納しています。
64行目 GeometryReader で囲んで ZStack が画面に占めるポイントサイズを取得します。
80行目 Image で画像を表示しています。ここでタップジェスチャを検知しています。
81 〜 83行目で画像を画面に対して拡大縮小表示するように指定します。
81行目 ここの .resizable() は絶対に忘れないでください。
85、93、99行目 タップした座標を取り出しています。
86行目、独自メソッド、ZStackにおいて画像が占める矩形サイズ(ポイント単位)を取得します。
87行目、独自メソッド、タップした座標を画面の内側にクランプします。
88行目、独自メソッド、画面の内側にクランプされたタップ座標(ポイント単位)を、画像のデータ座標(ピクセル単位)に変換します。

import SwiftUI

struct STRUCT_RGBA {
    var R: UInt8
    var G: UInt8
    var B: UInt8
    var A: UInt8
}

// 描画の色定義.
enum MY_COLOR {
    static let INSIDE  = Color( red: 0.0, green: 0.5, blue: 0.0, opacity: 1.0 )
    static let OUTSIDE = Color( red: 0.2, green: 0.2, blue: 0.2, opacity: 1.0 )
    static let CURSOR  = Color( red: 1.0, green: 1.0, blue: 0.0, opacity: 1.0 )
    static let RECT    = Color( red: 1.0, green: 0.0, blue: 1.0, opacity: 1.0 )
    static let TEXT    = Color( red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0 )
}

// デバッグ出力のテキストフォントサイズ.
enum MY_FONT {
    static let SIZE = 16.0
}

struct ContentView: View {

    // 縦が長い画像.
    let DATA_W =  256
    let DATA_H = 1024

    // 横が長い画像.
//    let DATA_W =  512
//    let DATA_H =  256

    // 縦横比が 1.0 の画像
//    let DATA_W =  256
//    let DATA_H =  256

    // 画像データの先頭アドレスと、そのデータをRGBAで解釈する場合の先頭アドレス.
    @State var Buffer: UnsafeMutableRawPointer?
    @State var DataRGBA: UnsafeMutablePointer<STRUCT_RGBA>?

    // 画像データを画面に表示するためのグッズ.
    @State var TheCGContext: CGContext? = nil
    @State var TheUIImage: UIImage? = nil

    // 画面をタップした座標.
    @State var TapGlobal = CGPoint( x: 0.0, y: 0.0 )

    // UIImage の内側に限定されたタップ座標.
    @State var TapInsideRect = CGPoint( x: 0.0, y: 0.0 )

    // UIImage が占める矩形領域.
    @State var RectImage = CGRect( x: 0.0, y: 0.0, width: 0.0, height: 0.0 )

    // UIImage の内側か外側かのフラグ.
    @State var FlagInsideRect = true

    // 画像データのピクセルXY座標.
    @State var TheX: Int = 0 // データの出現範囲は 0 〜 ( DATA_W - 1 )
    @State var TheY: Int = 0 // データの出現範囲は 0 〜 ( DATA_H - 1 )

    var body: some View {

        GeometryReader {

            geom in

            ZStack{

                let geom_w = geom.size.width
                let geom_h = geom.size.height

                // 最背面を塗りつぶす(条件により色を変える).
                Rectangle()
                    .foregroundColor( FlagInsideRect ? MY_COLOR.INSIDE : MY_COLOR.OUTSIDE )

                // 中層面で画像を表示する.
                if let TheUIImageUnwrapped = TheUIImage {

                    Image( uiImage: TheUIImageUnwrapped )
                        .resizable() // これを絶対に忘れるな.
                        .scaledToFit() // アスペクト比を守りつつ画像が欠けないように最大表示する.
                        .frame( width: geom_w, height: geom_h )
                        .onTapGesture { tap in
                            TapGlobal = tap // !!! ここは tap だけ !!!.
                            GetImageRectOnZStack( &RectImage, DATA_W, DATA_H, geom_w, geom_h )
                            GetInsideRectTapPoint( &TapInsideRect, &FlagInsideRect, TapGlobal, RectImage )
                            GetPixelXyFromTapXy( &TheX, &TheY, TapInsideRect, DATA_W, DATA_H, RectImage )
                        }
                        .gesture(
                            DragGesture( minimumDistance: 0 )
                                .onChanged { tap in
                                    TapGlobal = tap.location // !!! ここは tap の location !!!.
                                    GetImageRectOnZStack( &RectImage, DATA_W, DATA_H, geom_w, geom_h )
                                    GetInsideRectTapPoint( &TapInsideRect, &FlagInsideRect, TapGlobal, RectImage )
                                    GetPixelXyFromTapXy( &TheX, &TheY, TapInsideRect, DATA_W, DATA_H, RectImage )
                                }
                                .onEnded { tap in
                                    TapGlobal = tap.location // !!! ここは tap の location !!!.
                                    GetImageRectOnZStack( &RectImage, DATA_W, DATA_H, geom_w, geom_h )
                                    GetInsideRectTapPoint( &TapInsideRect, &FlagInsideRect, TapGlobal, RectImage )
                                    GetPixelXyFromTapXy( &TheX, &TheY, TapInsideRect, DATA_W, DATA_H, RectImage )
                                }
                            )

                    // TheUIImgeの外枠線を描画する.
                    MyPathRect( rect: RectImage )

                    // TheUIImageの内側に十字カーソルを描画する.
                    MyPathCursor( tap: TapInsideRect, rect: RectImage )

                }
                else
                {
                    // TheUIImageが適切に生成されていなければ何も表示しない.
                    Spacer()
                }

                // 最前面にデバッグ情報を表示する.
                VStack{

                    let screen_w = UIScreen.main.bounds.size.width
                    let screen_h = UIScreen.main.bounds.size.height

                    Text( String( format: "ScreenWH is %.1f*%.1f", screen_w, screen_h ))
                        .foregroundColor( MY_COLOR.TEXT )
                        .font(.system( size: MY_FONT.SIZE ))
                    Text( String( format: "GeomWH is %.1f*%.1f", geom_w, geom_h ))
                        .foregroundColor( MY_COLOR.TEXT )
                        .font(.system( size: MY_FONT.SIZE ))
                    Text( String( format: "TapXY is (%.1f*%.1f)", TapGlobal.x, TapGlobal.y ))
                        .foregroundColor( MY_COLOR.TEXT )
                        .font(.system( size: MY_FONT.SIZE ))
                    Text( String( format: "InsideRectTapXY is (%.1f*%.1f)", TapInsideRect.x, TapInsideRect.y ))
                        .foregroundColor( MY_COLOR.TEXT )
                        .font(.system( size: MY_FONT.SIZE ))
                    Text( String( format: "DataWH is %d*%d", DATA_W, DATA_H ))
                        .foregroundColor( MY_COLOR.TEXT )
                        .font(.system( size: MY_FONT.SIZE ))
                    Text( String( format: "ThePixelXY is (%d*%d)", TheX, TheY ))
                        .foregroundColor( MY_COLOR.TEXT )
                        .font(.system( size: MY_FONT.SIZE ))

                }

            } // ZStack end.
            .onAppear{

                // メモリを確保する.
                let numpix = DATA_W * DATA_H
                let size = MemoryLayout<STRUCT_RGBA>.stride * numpix
                let align = MemoryLayout<STRUCT_RGBA>.alignment
                Buffer = UnsafeMutableRawPointer.allocate( byteCount: size, alignment: align )

                // 連続メモリをRGBAの構造体の並びとして解釈する.
                DataRGBA = Buffer?.bindMemory( to: STRUCT_RGBA.self, capacity: numpix )

                // 確保したメモリに、ななめグラデーションデータを仕込む.
                SetGradDataRGBA( DataRGBA!, DATA_W, DATA_H )

                let one_scan_bytes = DATA_W * 4

                // ビットマップコンテキストを作成する.
                TheCGContext = CGContext(
                    data: DataRGBA, // ここは data: Buffer, としても同じ動作になる.
                    width: DATA_W,
                    height: DATA_H,
                    bitsPerComponent: 8,
                    bytesPerRow: one_scan_bytes,
                    space: CGColorSpaceCreateDeviceRGB(),
                    bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
                )

                if TheCGContext != nil
                {
                    let the_cgimage = TheCGContext!.makeImage()
                    if let the_cgimage = the_cgimage
                    {
                        TheUIImage = UIImage( cgImage: the_cgimage )
                    }
                    else
                    {
                        // デバッグコンソールに出力する.
                        print( "the_cgimage is nil.")
                    }
                }
                else
                {
                    // デバッグコンソールに出力する.
                    print( "the_cgcontext is nil." )
                }

            }
            .onDisappear {
                // メモリを破棄する.
                Buffer?.deallocate()
            }

        }

    }

    // ZStack にある UIImage の CGRect を取得するメソッド.
    func GetImageRectOnZStack(
        _ rect: inout CGRect,
        _ image_pix_w: Int,
        _ image_pix_h: Int,
        _ zstack_geom_w: Double,
        _ zstack_geom_h: Double
        ) -> Void
    {

        // 画像のピクセルアスペクト比を算出する.
        let asp_pix = Double( image_pix_w )/Double( image_pix_h )

        // 描画領域のアスペクト比を算出する.
        let asp_zstack = Double( zstack_geom_w )/Double( zstack_geom_h )

        if ( asp_pix >= asp_zstack )
        {
            // asp_pix のほうが大きい場合は上下に不定領域ができる.
            let tmp_w = zstack_geom_w
            let tmp_x = 0.0
            let tmp_h = tmp_w / asp_pix
            let tmp_y = ( zstack_geom_h - tmp_h ) * 0.5
            rect = CGRect( x: tmp_x, y: tmp_y, width: tmp_w, height: tmp_h )
            return
        }
        else
        {
            // asp_pix のほうが小さい場合は左右に不定領域ができる.
            let tmp_h = zstack_geom_h
            let tmp_y = 0.0
            let tmp_w = tmp_h * asp_pix
            let tmp_x = ( zstack_geom_w - tmp_w ) * 0.5
            rect = CGRect( x: tmp_x, y: tmp_y, width: tmp_w, height: tmp_h )
            return
        }

    }

    // タップした座標が矩形内部に入っているか確認して矩形内部のタップ座標として取得するメソッド.
    func GetInsideRectTapPoint(
            _ tap_inside_rect: inout CGPoint,
            _ flag_inside_rect: inout Bool,
            _ tap_global: CGPoint,
            _ rect: CGRect
            ) -> Void
    {

        if rect.contains( tap_global )
        {
            flag_inside_rect = true
        }
        else
        {
            flag_inside_rect = false
        }

        // いったんそのままタップ座標を代入する.
        tap_inside_rect.x = tap_global.x
        tap_inside_rect.y = tap_global.y

        let xs = rect.origin.x
        let ys = rect.origin.y
        let xe = xs + rect.width - 1.0
        let ye = ys + rect.height - 1.0

        // Rectangleの内側か外側か判定して inside_rect_tap.x と .y を調整する.
        if ( tap_inside_rect.x < xs )
        {
            tap_inside_rect.x = xs
        }

        if ( tap_inside_rect.y < ys )
        {
            tap_inside_rect.y = ys
        }

        if ( tap_inside_rect.x > xe )
        {
            tap_inside_rect.x = xe
        }

        if ( tap_inside_rect.y > ye )
        {
            tap_inside_rect.y = ye
        }

        return

    }

    // タップした座標から画像のピクセル座標を取得するメソッド.
    func GetPixelXyFromTapXy(
        _ x: inout Int,
        _ y: inout Int,
        _ tap: CGPoint,
        _ image_pix_w: Int,
        _ image_pix_h: Int,
        _ rect_image: CGRect
        ) -> Void
    {

        // タップ座標のオフセットを知る.
        let off_x = rect_image.origin.x
        let off_y = rect_image.origin.y

        // タップ座標からピクセル座標を算出するときの比率を算出する.
        let rate_x = Double( image_pix_w )/Double( rect_image.width )
        let rate_y = Double( image_pix_h )/Double( rect_image.height )

        // タップ座標のオフセットを考慮してタップ座標のゼロゼロを取得する.
        let tap_local_x = tap.x - off_x
        let tap_local_y = tap.y - off_y

        // 画面のタップ座標から画像データのピクセル座標に変換する.
        let tmp_x = tap_local_x * rate_x
        let tmp_y = tap_local_y * rate_y

        // 画像データのピクセル座標をまるめる.
        var rounded_x = Int( tmp_x.rounded())
        var rounded_y = Int( tmp_y.rounded())

        // 丸めたピクセル座標値が画像データの範囲内に収まるようにする.
        if ( rounded_x < 0 ) { rounded_x = 0 }
        if ( rounded_y < 0 ) { rounded_y = 0 }
        if ( rounded_x >= image_pix_w ) { rounded_x = image_pix_w - 1 }
        if ( rounded_y >= image_pix_h ) { rounded_y = image_pix_h - 1 }

        // 引数に戻す.
        x = rounded_x
        y = rounded_y

        return

    }

    // 画像にデータを仕込むメソッド.
    func SetGradDataRGBA(
            _ data: UnsafeMutablePointer<STRUCT_RGBA>,
            _ width: Int,
            _ height: Int
            )->Void {

        let w = width
        let h = height

        var value: UInt8 = 0

        for j in 0 ..< h{
            var adrs = w * j
            for i in 0 ..< w{
                let tmp = i + j
                value = UInt8( tmp % 256 )
                data[ adrs ].R = 0x00;  // R
                data[ adrs ].G = 0x00;  // G
                data[ adrs ].B = value; // B
                data[ adrs ].A = 0xff;  // A
                adrs += 1
            }
        }

        return

    }

}

// 指定した矩形の外枠線を描画する Path を含むビュー.
struct MyPathRect: View {

    var rect: CGRect

    var body: some View {

        Path {
            path in

            let xs = rect.origin.x
            let ys = rect.origin.y
            let xe = xs + rect.width - 1.0
            let ye = ys + rect.height - 1.0

            let point0 = CGPoint( x: xs, y: ys )
            let point1 = CGPoint( x: xe, y: ys )
            let point2 = CGPoint( x: xe, y: ye )
            let point3 = CGPoint( x: xs, y: ye )

            path.move   ( to: point0 )
            path.addLine( to: point1 )
            path.addLine( to: point2 )
            path.addLine( to: point3 )
            path.addLine( to: point0 )

        }
        .stroke( MY_COLOR.RECT, lineWidth: 2.0 )

    }

}

// 指定した矩形の中にタップした座標を十字描画する Path を含むビュー.
struct MyPathCursor: View {

    var tap: CGPoint
    var rect: CGRect

    var body: some View {

        Path {
            path in

            let cursor_x = tap.x
            let cursor_y = tap.y

            let xs = rect.origin.x
            let ys = rect.origin.y
            let xe = xs + rect.width - 1.0
            let ye = ys + rect.height - 1.0

            path.move   (to: CGPoint( x:  cursor_x, y:       ys ))
            path.addLine(to: CGPoint( x:  cursor_x, y:       ye ))
            path.move   (to: CGPoint( x:        xs, y: cursor_y ))
            path.addLine(to: CGPoint( x:        xe, y: cursor_y ))
        }
        .stroke( MY_COLOR.CURSOR, lineWidth: 2.0 )

    }

}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

画像の拡大縮小の表示については下記の記事も参考にしてみてください。

画像の等倍表示、フィット表示、フィル表示

画像を画面上で拡大縮小表示するときに scaledToFit や scaledToFill などのモディファイアが併用できます。この動作の詳細を実行例で紹介します。