20200226のSwiftに関する記事は7件です。

トークン更新を自前でやってみた

はじめに

まずはじめに結論を言うと、 Timer の使い方の一つを紹介するという内容になります。
トークンの更新処理などは SDK などに委ねるのが一般的なので、処理方法の参考としてご覧ください。

自動更新処理

final class NetworkManager {
    private enum Const {
        /// 10 minutes ago
        static let timeGap: TimeInterval = -60 * 10
    }

    public var token: String {
        _token
    }

    private var _token = ""
    private var timer: Timer?
    private var expiration: Date = Date() {
        didSet {
            timer?.invalidate()
            // 期限の10分前にトークンの更新をリクエスト
            timer = Timer(fire: Date(timeInterval: Const.timeGap, since: expiration), interval: .zero, repeats: false) { [weak self] _ in
                self?.updateToken()
            }
            if let timer = timer {
                // Timer を init して生成している関係で必要になる処理
                RunLoop.main.add(timer, forMode: .default)
            }
        }
    }

    deinit {
        // Class が破棄される時に Timer も無効にする
        timer?.invalidate()
    }

    private func updateToken() {
        createToken(
            success: { [weak self] info in
                self?.expiration = info.expiration
                self?._token = info.token
            },
            failure: { error in
                debugPrint(error)
            }
        )
    }
}
extension NetworkManager {
    public struct AuthInfo {
        let token: String
        let expiration: Date
    }
    // サーバとの通信処理を再現
    public func createToken(success: @escaping (AuthInfo) -> Void, failure: @escaping (Error) -> Void) {
        let token = "E9rF7M3xgCE1taG6Qz7t" // SAMPLE
        let tomorrow = Date(timeIntervalSinceNow: 60 * 60 * 24) // SAMPLE
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            success(AuthInfo(token: token, expiration: tomorrow))
        }
    }
}

さいごに

「SDKから常に期限が切れていない Token を取得できるから便利!」という処理の裏の実装方法として、このようなパターンも考えられます。
ただ、端末の時間が間違っていると、正常に動作しない問題を抱えていることに気がつくと思います。
はじめにも書きましたが、処理手法の参考としてみていただければと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UICorectionViewで指定したCellへ移動する処理

当日cellへ移動処理表示後のUIViewControllerのライフサイクルのviewDidAppear
にてscrollToItemを利用してスクロールとanimated: false指定で処理。

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        print("aaaaaaaaaaaaaaaaa")
        let indexPath = IndexPath(item: elapsedDays + 5, section: 0)
        myCollectView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
     //tableViewではitemがrowで指定する。
        //myCollectView.scrollToItem(at: IndexPath(row: 100, section: 0), at: .left, animated: false)
        //myCollectView.reloadData()
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

swift 誕生日から今日まで何日目か?

swiftのカレンダーアプリ作成でUICorectionViewで縦にスクロールしてカレンダーを表示する際に対応する開始日付から今日までの日数によって自動で当日へスクロールさせる時に下記の計算が必要だったので投稿します。他に良い方法があれば御伝授頂きたいとおもいます。よろしくお願いします。

let dateFormater = DateFormatter()
dateFormater.locale = Locale(identifier: "ja_JP")
dateFormater.dateFormat = "yyyy/MM/dd HH:mm:ss"
let date1 = dateFormater.date(from: "2015/10/01 12:12:12")
let date2 = Date()
let elapsedDays = Calendar.current.dateComponents([.day], from: date1!, to: date2).day
print(elapsedDays)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

XCTest(Unit Test)で明示的に書かれていないいくつかの仕様

単体テストで画面遷移もやっちゃおう、と思って格闘したときの覚書です。

アプリの起動はテスト実行時一回のみ行われる

テスト対象のアプリは、テストの実行時一回のみ行われます。

@testable import app

つまりAppDelegateは初回のテストケースで一度だけ走ります。
各テストケースでは走リません。

デフォルトのテストケースの実行順序はクラス名順 -> テストケースメソッド名順

テストケースの実行順序は名前の順になります。
個人的にはクラスの中で、上から下に舐めていって欲しい気持ちがあったのですが、それは仕様みたいです。
テストケースの実行をランダムにするオプションはあるんですが、デフォルトは名前順。

基本テストケースの実行順序に依存するようなテストコードを書くな、というのはあるんでしょうけど、
何らか制約が生じてしまうときは、名前で縛るしかないのかなあという感じです。
たとえばtest_1_何かの確認test_2_何かの確認みたいな。

あるテストケースでアプリに加えた変更は次のテストケースに引き継がれる

テストケースのたびにアプリの状態はイニシャルしてしまって構わないのですが、
多分パフォーマンスの問題だと思うんですが、イニシャルはされず、次に引き継がれます。
別にModelクラスならそんなに困ることはないんですが、UIが関わるクラスだと結構キツイです。

tearDown()でちゃんと後始末して、常にきれいな状態で次のテストケースに引き継げるように記述できれば良いんですが、
テストのことをあまり考えずにつくったアプリだとキツイときもあって、みんなどうしてんだろうなあって思いました。

Class単位にsetup()/tearDown()ができる

これは明示的に書かれていないということはなく、
テストの本読むとXCTestのライフサイクルとしてちゃんと書かれていますが、
サンプルコートの中になくて、使わないので盲点になっていました。

XCTestCaseのclassメソッドsetup()/tearDown()はテストclassに対して一回。
通常のsetup()/tearDown()はテストケースに対して一回。
何を言っているのかというと、

XCTestCaseを継承したクラス
    class override func setUp() {
        <#code#>
    }

    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    class override func tearDown() {
        <#code#>
    }

この一番上と一番下のクラスメソッドはクラス中で一回だけ、
二番目、三番目のメソッドは各テストケースごとに実行されます。

僕は画面遷移する前にアプリが持っている情報をテスト前に退避して、
テストでは仮のデータ突っ込んでめちゃくちゃにして、テスト後は退避したものをきれいに戻す、みたいな書き方をしようとしたんですが、
使ってみたらクラスメソッドだと子クラスの中に書いた変数が使えないので、退避先がなくて実現しませんでした。

本気であまり使い所ないかもしれませんね……

UIテストだとアプリ自体の起動・終了が制御できる

アプリの状態をイニシャルできないというのは、UIテストだったら、アプリの起動・終了が制御できるので、多少は良いかなと思います。

XCUIApplication().launch()

画面遷移ならむしろUIテストで書くのが筋なんですかね?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift で UITextView の URL リンクを短縮表示させる。

0.はじめに

Swift で UITextView に表示される URL リンクを短縮表示させたかったので、やってみました。

ついでに、キーワード検索時の強調表示もできる様にしてます。

以下の記事を参考にさせて頂きました。

感謝 ♪♪♪

?‍♂️?‍♂️?‍♂️

1.コード

今回は、UITextView の Extension として、setAttributedText という関数を追加しました。

パラメータの説明は、以下。

  • text: String : [元となるテキスト文字列]
  • shortUrlMaxNum: Int = Int() : [短縮されるURLの最大文字数]
  • keyword: String? = nil : [検索キーワード]
  • keywordBackgroundColor: UIColor = .yellow : [検索キーワードの強調される背景色]
UITextView+Extension.swift
//
//  UITextView+Extension.swift
//

import UIKit

extension UITextView {
    func setAttributedText(text: String, shortUrlMaxNum: Int = Int(), keyword: String? = nil, keywordBackgroundColor: UIColor = .yellow) -> Void {
        var tempText: String = text
        var links: [(url: URL, range: NSRange)] = []
        if shortUrlMaxNum > 0 {
            if let _detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) {
                var truncateCount: Int = 0
                for link in _detector.matches(in: tempText, range: NSMakeRange(0, tempText.utf16.count)) {
                    let startIndex = tempText.utf16.index(tempText.startIndex, offsetBy: (link.range.location - truncateCount))
                    let endIndex = tempText.utf16.index(startIndex, offsetBy: link.range.length)
                    let _tempText = tempText[startIndex..<endIndex]
                    if let _url = link.url, let _ = _url.host, _tempText == _url.absoluteString {
                        var range: NSRange = link.range
                        if _url.absoluteString.count > shortUrlMaxNum {
                            let replacementString: String = _url.absoluteString.prefix(shortUrlMaxNum) + "..."
                            range = NSMakeRange(link.range.location - truncateCount, replacementString.count)
                            tempText.replaceSubrange(
                                Range<String.Index>(NSMakeRange(range.location, link.range.length), in: tempText)!,
                                with: replacementString)
                            truncateCount += (link.range.length - replacementString.count)
                        } else {
                            range = NSMakeRange(link.range.location - truncateCount, _url.absoluteString.count)
                        }
                        links.append((url: _url, range: range))
                    }
                }
            }
        }
        let attrText: NSMutableAttributedString = NSMutableAttributedString(string: tempText, attributes: [
            .font : self.font as Any,
            .foregroundColor : self.textColor as Any,
        ])
        for link in links {
            attrText.addAttribute(.link, value: link.url.absoluteString, range: link.range)
        }
        if let _keyword = keyword {
            if let _regex = try? NSRegularExpression(pattern: _keyword) {
                for match in _regex.matches(in: text, range: NSMakeRange(0, text.count)) as [NSTextCheckingResult] {
                    attrText.addAttribute(.backgroundColor, value: keywordBackgroundColor, range: match.range)
                }
            }
        }
        self.attributedText = attrText
    }
}


で、こんな感じに表示されます。

IMG_0152.png


99.ハマりポイント

  • 短縮URL後のテキスト文字列の編集が、結構面倒臭かったですね…。バグってないかちょっと心配…。

  • あと、tempText.utf16 を使っている箇所がありますが、これは絵文字対策で入れたものです。これも、結構面倒臭かった…。

???

XX.まとめ

もしかしたら、バグあるかもしれないので、実装する場合は、自己責任でお願いします!

?‍♂️?‍♂️?‍♂️

あと、もっと簡単な方法があれば、教えて頂けるとと嬉しいです!

以上、ご参考になれば ♪♪♪

???

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

XCUITestからplistを参照する

UITest実行時は少し参照方法が違うようです。
Bundleが違うということですね。

Bundle.main.path(forResource: "Info", ofType: "plist")とにらめっこしてましたが

例として、ファイルツリーのhogeUITest以下にInfo.plistを配置し、XCTestCaseから参照する場合は以下でOK

let bundle = Bundle(for: type(of: self))
if let filePath = bundle.path(forResource: "Info", ofType: "plist") {
    if let dict = NSDictionary(contentsOfFile: filePath) {
        if let value = dict["key"] as? String {
            print(value)
        }
    }
}

トラブルシューティング

目的のkeyが見つからないときは、想定しているplistか疑う必要があります。
XCTestCaseから呼ぶ場合はBundle(for: type(of: self))でOKですが、
テストファイルの中ではなく、通常のアプリ内のクラスに実装すると、当然selfではなくなるので注意です。

困ったときはbundleBundle.allBundlesとして探してみてください。
[bundle]でとれるのでまわして探しても見つからないか確認しましょう。

闇実装

.allBundlesというのを見つけました。Test側もそうじゃない方からも探せます。

let bundles: [Bundle] = Bundle.allBundles
for bundle in bundles {
    if let filePath = bundle.path(forResource: "Info", ofType: "plist") {
        if let dict = NSDictionary(contentsOfFile: filePath) {
            if let value = dict["key"] as? String {
                print("みつけた!")
            }
        }
    }

}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【ARKit】3Dアニメーションの切り替え

はじめに

状況に応じてアニメーションを切り替えたかったので、そのメモ。

実行環境

やりたいこと

  • iPhoneのミュージックなど、他のアプリでの音楽が再生/停止の検知。
  • 再生中はキャラクタが踊り、停止すると踊りを止めるようにアニメーションを切り替える。 (昔あった玩具のフラワーロック的なイメージ)

Mixamoで作るアニメーションは、1つの3Dモデルに1つのアニメーションを割り当てるので、座ってる状態(sitting.scn)と踊っている状態(swing.scn)の二つのシーンからnodeを取りだして、差し替えることでアニメーションを切り替えることにしました。

【座ってる状態(sitting.scn)】
スクリーンショット 2020-02-25 0.46.45.png

【踊っている状態(swing.scn)】
スクリーンショット 2020-02-25 0.46.58.png

他のアプリでのAVAudioSessionの状態を検知する

他のアプリでの音楽が再生/停止されたかは、AVAudioSession.silenceSecondaryAudioHintNotificationで 検知できるようなので、通知されたときに呼び出される関数(handleSecondaryAudio)をNotificationCenterに登録します。

silenceSecondaryAudioHintNotificationの登録処理
    func setupNotifications() {
        // AVAudioSession:初期化
        try! AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.ambient)
        try! AVAudioSession.sharedInstance().setActive(true)

        // 他アプリでのサウンド再生/停止を監視
        let nc = NotificationCenter.default
        nc.addObserver(self, selector: #selector(self.handleSecondaryAudio), name: AVAudioSession.silenceSecondaryAudioHintNotification, object: nil)
    }

setupNotificationsは、viewDidLoadなどで呼び出してておきます。また、AVAudioSession.silenceSecondaryAudioHintNotification は、再生/停止が変化したの通知のため、初回のみ AVAudioSession.sharedInstance().isOtherAudioPlaying でアプリ起動時の再生/停止状態を判断して初期化します。

再生/停止検知の初期化
    let val_idle:String? = "sitting"
    let val_swing:String? = "swing"

    var selectedItem: String?

    override func viewDidLoad() {
        super.viewDidLoad()

        //(中略)

        setupNotifications()

        if AVAudioSession.sharedInstance().isOtherAudioPlaying {
            // 再生中!!
            print("---再生中")
            selectedItem = val_swing
        } else {
            print("---停止中")
            selectedItem = val_idle
        }
    }

他のアプリでの再生/停止検知時の処理

notification.userInfoの AVAudioSession.SilenceSecondaryAudioHintType を取りだして、その値で再生(.begin)、停止(.end)を判断します。

そして切り替えるシーン名を変更して(selectedItem)、キャラクタのnodeを差し替える処理(changeAllNodes())を呼び出します。

再生/停止検知時の処理
    /// 他アプリでのサウンド再生/停止を監視
    @objc func handleSecondaryAudio(notification: Notification) {
        // Determine hint type
        print("---check audio")

        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] as? UInt,
            let type = AVAudioSession.SilenceSecondaryAudioHintType(rawValue: typeValue) else {
                return
        }

        if type == .begin {
            // Other app audio started playing - mute secondary audio.
            print("---audio:begin")

            // キャラクタのnodeを差し替える
            selectedItem = val_swing
            changeAllNodes()
        } else if type == .end {
                // Other app audio started playing - mute secondary audio.
            print("---audio:stop")

            // キャラクタのnodeを差し替える
            selectedItem = val_idle
            changeAllNodes()
        } else {
            // Other app audio stopped playing - restart secondary audio.
        }
    }

キャラクタのnodeを差し替える

self.mainSceneView から1つずつノードを取りだして、現在表示されているノード(prevItem)を新しいノード(selectedItem)に差し替えます。

キャラクタのnodeを差し替える
    // キャラクタのnodeを差し替える
    func changeAllNodes() -> Void {
        var prevItem:String?

        if (selectedItem == val_swing){
            prevItem = val_idle
        } else {
            prevItem = val_swing
        }

        // 追加されている全ての子ノードを1つずつ取り出す
        for Node : AnyObject in self.mainSceneView.scene.rootNode.childNodes{
            if (Node as! SCNNode).name == nil  {
                print("name is nil")
            } else {
                if Node.name == prevItem{

                    if let selectedItem = self.selectedItem {

                        // .scnファイルから新しい3Dモデルのノードを作成
                        let scene = SCNScene(named: "art.scnassets/\(selectedItem).scn")
                        let newNode = (scene?.rootNode.childNode(withName: selectedItem, recursively: false))!

                        // 3Dモデルの配置(現在と同じ位置に)
                        newNode.position = Node.position

                        // 3Dモデルのサイズを変更(現在と同じサイズに)
                        newNode.scale = Node.scale

                        // 3Dモデルに名前をつける(削除用)
                        newNode.name = selectedItem

                        // 新しい3Dモデルのノードに差し替える
                        self.mainSceneView.scene.rootNode.replaceChildNode(Node as! SCNNode, with: newNode)

//                        // シーンに追加
//                        self.mainSceneView.scene.rootNode.addChildNode(newNode)
//
//                        // 古い3Dモデルを削除
//                        Node.removeFromParentNode();
                }
            }

        }

    }

※最初は、新しいノードを追加>古いノードを削除としていましたが、replaceChildNode(_:with:)で差し替えできるとがわかったので、書き換えました。

完成

できました!

↓音楽停止中は座り込み...

↓音楽再生中は踊る!

音楽の再生/停止をコントロールセンターで行うと、切り替え時にタイムラグがあるので、リモコン付きのイヤフォンで再生/停止を行うと挙動がわかりやすいです。

最後にスクリプト全体を載せておきます。

ViewController.swift
import UIKit
import ARKit
import AVFoundation

class ViewController: UIViewController{

    @IBOutlet weak var mainSceneView: ARSCNView!

    let configuration = ARWorldTrackingConfiguration()

//    let val_idle:String? = "idle"
    let val_idle:String? = "sitting"
    let val_swing:String? = "swing"

    var selectedItem: String?

    override func viewDidLoad() {
        super.viewDidLoad()

        initialize()
        registerGestureRecognizers()

        setupNotifications()

        if AVAudioSession.sharedInstance().isOtherAudioPlaying {
            // 再生中!!
            print("---再生中")
            selectedItem = val_swing
        } else {
            print("---停止中")
            selectedItem = val_idle
        }
    }

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

    func setupNotifications() {
        // AVAudioSession:初期化
        try! AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.ambient)
        try! AVAudioSession.sharedInstance().setActive(true)

        // 他アプリでのサウンド再生/停止を監視
        let nc = NotificationCenter.default
        nc.addObserver(self, selector: #selector(self.handleSecondaryAudio), name: AVAudioSession.silenceSecondaryAudioHintNotification, object: nil)


    }
    /// ARSCNiew初期化設定
    func initialize (){

        self.mainSceneView.debugOptions = [ARSCNDebugOptions.showWorldOrigin, ARSCNDebugOptions.showFeaturePoints]
        self.configuration.planeDetection = .horizontal
        self.mainSceneView.session.run(configuration)
        self.mainSceneView.autoenablesDefaultLighting = true
    }

    /// メインのビューのタップを検知するように設定する
    func registerGestureRecognizers() {

        let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapped))
        self.mainSceneView.addGestureRecognizer(tapGestureRecognizer)

        // ロングプレスイベントハンドラの登録
        let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(longPressView))
        self.mainSceneView.addGestureRecognizer(longPressGesture)
    }

    @objc func tapped(sender: UITapGestureRecognizer) {
        // タップされた位置を取得する
        let sceneView = sender.view as! ARSCNView
        let tapLocation = sender.location(in: sceneView)

        // タップされた位置のARアンカーを探す
        let hitTest = sceneView.hitTest(tapLocation, types: .existingPlaneUsingExtent)
        if !hitTest.isEmpty {
            // タップした箇所が取得できていればitemを追加
            self.addItem(hitTestResult: hitTest.first!)
        }
    }

    // 長押しでキャラクタを削除する
    @objc func longPressView(sender: UILongPressGestureRecognizer) {
        print("----長押し!")

        if sender.state == .began {
            let location = sender.location(in: self.mainSceneView)
            let hitTest  = self.mainSceneView.hitTest(location)

            print("---hitTest:%d",hitTest)

            if let result = hitTest.first  {

                if ((result.node.name) != nil){
                    print("--.name:%d",result.node.name!)
                }
                if ((result.node.parent!.name) != nil){
                    print("--parent.name:%d",result.node.parent!.name!)
                }

                // 3Dアニメーションモデルは、複数パーツで構成されるため、親ノードの名前で判定・削除する
                if result.node.parent!.name == selectedItem
                {
                    result.node.parent!.removeFromParentNode();
                }
            }
        }
    }

    /// アイテム配置メソッド
    func addItem(hitTestResult: ARHitTestResult) {
        if let selectedItem = self.selectedItem {

            // .scnファイルから新しい3Dモデルのノードを作成
            let scene = SCNScene(named: "art.scnassets/\(selectedItem).scn")
            let node = (scene?.rootNode.childNode(withName: selectedItem, recursively: false))!

            // 現実世界の座標を取得
            let transform = hitTestResult.worldTransform
            let thirdColumn = transform.columns.3

            // 3Dモデルの配置
            node.position = SCNVector3(thirdColumn.x, thirdColumn.y, thirdColumn.z)

            // 3Dモデルのサイズを変更
            node.scale = SCNVector3(0.05, 0.05, 0.05)

            // 3Dモデルに名前をつける
            node.name = selectedItem

            // シーンに追加
            self.mainSceneView.scene.rootNode.addChildNode(node)
        }
    }

    /// 他アプリでのサウンド再生/停止を監視
    @objc func handleSecondaryAudio(notification: Notification) {
        // Determine hint type
        print("---check audio")

        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] as? UInt,
            let type = AVAudioSession.SilenceSecondaryAudioHintType(rawValue: typeValue) else {
                return
        }

        if type == .begin {
            // Other app audio started playing - mute secondary audio.
            print("---audio:begin")

            // キャラクタのnodeを差し替える
            selectedItem = val_swing
            changeAllNodes()
        } else if type == .end {
                // Other app audio started playing - mute secondary audio.
            print("---audio:stop")

            // キャラクタのnodeを差し替える
            selectedItem = val_idle
            changeAllNodes()
        } else {
            // Other app audio stopped playing - restart secondary audio.
        }
    }

    // キャラクタのnodeを差し替える
    func changeAllNodes() -> Void {
        var prevItem:String?

        if (selectedItem == val_swing){
            prevItem = val_idle
        } else {
            prevItem = val_swing
        }

        // 追加されている全ての子ノードを1つずつ取り出す
        for Node : AnyObject in self.mainSceneView.scene.rootNode.childNodes{
            if (Node as! SCNNode).name == nil  {
                print("name is nil")
            } else {
                if Node.name == prevItem{

                    if let selectedItem = self.selectedItem {

                        // .scnファイルから新しい3Dモデルのノードを作成
                        let scene = SCNScene(named: "art.scnassets/\(selectedItem).scn")
                        let newNode = (scene?.rootNode.childNode(withName: selectedItem, recursively: false))!

                        // 3Dモデルの配置(現在と同じ位置に)
                        newNode.position = Node.position

                        // 3Dモデルのサイズを変更(現在と同じサイズに)
                        newNode.scale = Node.scale

                        // 3Dモデルに名前をつける(削除用)
                        newNode.name = selectedItem

                        // 新しい3Dモデルのノードに差し替える
                        self.mainSceneView.scene.rootNode.replaceChildNode(Node as! SCNNode, with: newNode)
//                        // シーンに追加
//                        self.mainSceneView.scene.rootNode.addChildNode(newNode)
//
//                        // 古い3Dモデルを削除
//                        Node.removeFromParentNode();
                }
            }

        }

    }
}

}

まとめ

Mixamoで作るモデルが、1つの3Dモデルに1つのアニメーションなので、このような方法を取りましたが、別の3Dアプリなどで複数のアニメーションを1つのオブジェクトに内包させることができたりするんですかね?(Unityのようにアニメーション設定そのものを別ファイルとして、モデルに適用するような)

もし他にいい方法があるようなら、教えてください。

以下の記事が参考になりました。ありがとうございます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む