🎴

SwiftUIでキラカードの光り方を表現する

2023/05/26に公開

概要

  • 以前CSSでポケモンカードのホログラフを表現するサイトが話題になりました。

https://twitter.com/joebell_/status/1581843454379728896

  • こちらに触発され、私も簡単なキラカードの光り方をSwiftUIで実装してみました。
  • またnoppeさんのこちらのツイートより、SwiftUIで表現できるんだというキッカケをもらいました。

https://twitter.com/noppefoxwolf/status/1575206701590286336

今回作ったもの

https://twitter.com/ikeh1024/status/1662022063576723459

GitHub

参考

カードの外形の作成

  • まずはSwiftUIでカードの外形を作成していきます。
struct SampleCardView: View {
    
    private static let cardCornerRadius: CGFloat = 20
    private static let cardSize: CGSize = .init(width: 367, height: 512)
    
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: Self.cardCornerRadius)
                .fill(.gray)
            RoundedRectangle(cornerRadius: Self.cardCornerRadius)
                .stroke(.white, lineWidth: 1)
        }
        .frame(width: Self.cardSize.width, height: Self.cardSize.height)
        .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 20)
        .padding(50)
    }
}

ホバーの状態とマウスの位置を取得

  • macOSでは以下のメソッドを使ってView上のマウスの位置やホバーしているかの状態を取得することができます。
  • また今回はView上の位置を知りたいので、coordinateSpaceに.localを指定します
  • 下記のように左上を原点、右下を(1, 1)として現在のマウスの位置を計算し、hoverLocationRatioへ保存しています。
struct SampleCardView: View {
    
    private static let cardCornerRadius: CGFloat = 20
    private static let cardSize: CGSize = .init(width: 367, height: 512)
    
    @State private var isHovering = false
    @State private var hoverLocationRatio: CGPoint = .init(x: 0.5, y: 0.5)
    
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: Self.cardCornerRadius)
                .fill(.gray)
            RoundedRectangle(cornerRadius: Self.cardCornerRadius)
                .stroke(.white, lineWidth: 1)
        }
        .frame(width: Self.cardSize.width, height: Self.cardSize.height)                
        .brightness(isHovering ? 0.05 : 0)
        .contrast(isHovering ? 1.3 : 1.0)     
        .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 20)
        .onContinuousHover(coordinateSpace: .local) { phase in
            switch phase {
            case .active(let location):
                hoverLocationRatio = .init(x: location.x / Self.cardSize.width,
                                           y: location.y / Self.cardSize.height)
                print(hoverLocationRatio)
                isHovering = true
            case .ended:
                withAnimation {
                    isHovering = false
                    hoverLocationRatio = .init(x: 0.5, y: 0.5)
                }
            }
        }        
        .padding(50)
    }
}

  • ホバー時に少しカードを目立たせるため、明度とコントラストを上げています。
.brightness(isHovering ? 0.05 : 0)
.contrast(isHovering ? 1.3 : 1.0)

wip

マウス位置によってカードを回転させる

  • 先程取得したマウスの位置を使用して、カードを回転させます。
  • 下記のようにrotationDegreesを定義しrotation3DEffect(_:axis:anchor:anchorZ:perspective:)を使って回転の効果を反映させます。
  • 計算している内容としては下記の通りです。
    • カードの真ん中は回転していない状態
    • 真ん中から左右または上下によって正負が入れ替わる
    • カードの端では最大maxRotationDegreeだけ回転させる
@State private var hoverLocationRatio: CGPoint = .init(x: 0.5, y: 0.5)

private var rotationDegrees: CGPoint {
    let maxRotationDegree: Double = 10  // 最大回転量
    return  CGPoint(x: -maxRotationDegree * ((hoverLocationRatio.x - 0.5) / 0.5),
                    y: maxRotationDegree  * ((hoverLocationRatio.y - 0.5) / 0.5))
}
var body: some View {
        ZStack {
            ...
        }
        .onContinuousHover(coordinateSpace: .local) { phase in
            ...
        }
        .rotation3DEffect(Angle(degrees: rotationDegrees.x),
                          axis: (x: 0, y: 1.0, z: 0),
                          anchorZ: 0,
                          perspective: 1.0)
        .rotation3DEffect(Angle(degrees: rotationDegrees.y),
                          axis: (x: 1.0, y: 0, z: 0),
                          anchorZ: 0,
                          perspective: 1.0)
        .padding(50)
    }

wip

画像の重ね合わせ

  • .overlayを使って画像を重ね合わせます。
  • この際にBlendModeに.colorDodgeを指定しています。
  • また今回Gif画像に関してはSDWebImageSwiftUIを使用して表示しています。
import SDWebImageSwiftUI

var body: some View {
    ZStack {
	...
    }
    ...                
    .overlay() {
        Image("holo")
            .resizable()
            .blendMode(.colorDodge)
            .opacity(0.3)
    }
    .overlay() {
        AnimatedImage(name: "sparkles.gif", isAnimating: .constant(true))
            .resizable()
            .blendMode(.colorDodge)
            .opacity(0.3)
    }
    
    .onContinuousHover(coordinateSpace: .local) { phase in
    ...        
    }
    ...        
}
  • 画像は以下のものをお借りしました。
https://assets.codepen.io/13471/holo.png
https://assets.codepen.io/13471/sparkles.gif

キラカードの光り方

...
 .overlay() {
     AnimatedImage(name: "sparkles.gif", isAnimating: .constant(true))
         ...
 }
 .gradientHolographicEffect(locationRatioX: hoverLocationRatio.x)
 .clipShape(
     RoundedRectangle(cornerRadius: 20)
 )
 ...
 
 extension View {
    func gradientHolographicEffect(locationRatioX: Double) -> some View {

        let gradientLocationCenter = min(max(locationRatioX, 0.11), 0.89)  // 0.11 ~ 0.89
        let gradient = Gradient(stops: [
            .init(color: .clear, location: 0),
            .init(color: Color(red: 0, green: 231/255, blue: 1), location: gradientLocationCenter - 0.1),
            .init(color: .blue, location: gradientLocationCenter),
            .init(color: Color(red: 1, green: 0, blue: 231/255), location: gradientLocationCenter + 0.1),
            .init(color: .clear, location: 1.0)
        ])
        
        return self
            .overlay() {
                LinearGradient(gradient: gradient,
                               startPoint: .leading,
                               endPoint: .trailing)
                .padding(-60)  // カード全体を覆えるように大きめのマージンをとる
                .opacity(1.0)
                .rotationEffect(Angle(degrees: 20))
                .blendMode(.overlay)
            }
    }
}

wip

  • 下記の.clipShapeが大事で、無い場合は以下のように別々の層として表示されてしまいます。
    • (.clipShapeを使うとうまくViewが重ね合わさる訳を知っている方がいたら教えてください…!)
.clipShape(
     RoundedRectangle(cornerRadius: 20)
 )

wip

  • 同様に虹色の光り方も作成できます。
func rainbowHolographicEffect(isOn: Bool = true, locationRatioX: Double) -> some View {
    let gradientLocationCenter = min(max(locationRatioX, 0.21), 0.79)  // 0.21 ~ 0.79
    let gradient = Gradient(stops: [
        .init(color: .clear, location: 0),
        .init(color: Color(hex: "#ec9bb6"), location: gradientLocationCenter - 0.2),
        .init(color: Color(hex: "#ccac6f"), location: gradientLocationCenter - 0.1),
        .init(color: Color(hex: "#69e4a5"), location: gradientLocationCenter),
        .init(color: Color(hex: "#8ec5d6"), location: gradientLocationCenter + 0.1),
        .init(color: Color(hex: "#b98cce"), location: gradientLocationCenter + 0.2),
        .init(color: .clear, location: 1.0)
    ])
    
    return self
        .overlay() {
            if isOn {
                LinearGradient(gradient: gradient,
                               startPoint: .leading,
                               endPoint: .trailing)
                .padding(-60)
                .opacity(1.0)
                .rotationEffect(Angle(degrees: 20))
                .blendMode(.overlay)
            }
        }
}

wip

ハイライト効果

extension View {
    func highlightEffect(isOn: Bool = true, hoverLocationRatio: CGPoint) -> some View {
        self
            .overlay() {
                if isOn {
                    RadialGradient(gradient: Gradient(colors: [.white.opacity(0.5), .clear]),
                                   center: .init(x: hoverLocationRatio.x, y: hoverLocationRatio.y),
                                   startRadius: 0,
                                   endRadius: 200)
                }
            }
    }
}

highlight2

  • 画像に対して適用すると効果が分かりやすいです。

highlight

まとめ

Discussion